diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md deleted file mode 100644 index 9d8cf6c1b3..0000000000 --- a/.changeset/adaptive-opus-five.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. diff --git a/.changeset/agent-manager-diff-base-override.md b/.changeset/agent-manager-diff-base-override.md new file mode 100644 index 0000000000..5d38f0e6cb --- /dev/null +++ b/.changeset/agent-manager-diff-base-override.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed. diff --git a/.changeset/agent-manager-diff-scope-selector.md b/.changeset/agent-manager-diff-scope-selector.md new file mode 100644 index 0000000000..341e5ee768 --- /dev/null +++ b/.changeset/agent-manager-diff-scope-selector.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged. diff --git a/.changeset/agent-manager-multi-project.md b/.changeset/agent-manager-multi-project.md new file mode 100644 index 0000000000..1212a52f99 --- /dev/null +++ b/.changeset/agent-manager-multi-project.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add an experimental multi-project foundation for Agent Manager behind the Multi-Project Agent Manager toggle in Kilo Settings > Experimental. A persistent project registry catalogs additional Git repositories across restarts while the current workspace repository stays the pinned default project, and all repository-bound services (state, worktrees, setup scripts, stale tracking, polling) are now owned by immutable per-project contexts that can be activated, expanded, and switched through new project protocol messages. Every expanded project runs its own git stats and PR pollers, so all visible project accordions show live data at the same time; switching projects keeps previously visited projects expanded and restores the exact worktree or session that was open there. Sessions created anywhere (sidebar, another window, the CLI) appear in the owning project's list immediately, and open tabs stay private to the project they were opened in. Single-project behavior is unchanged when the experiment is disabled. diff --git a/.changeset/agent-manager-session-scope-selection.md b/.changeset/agent-manager-session-scope-selection.md new file mode 100644 index 0000000000..7d34311656 --- /dev/null +++ b/.changeset/agent-manager-session-scope-selection.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Make the Agent Manager diff review follow the sidebar selection instead of a single session. Switching session tabs inside a worktree no longer refetches the Branch, Staged, and Unstaged scopes, the Session scope now swaps to the active session's changes on tab switch, and the Local tab gains the Session scope so sessions running in the workspace can be reviewed on their own. The Session scope shows a notice when snapshots are disabled instead of a blank list, worktrees without an open session now still show their branch diff, and the Apply dialog lists the worktree's changes again. diff --git a/.changeset/agent-manager-side-terminal.md b/.changeset/agent-manager-side-terminal.md deleted file mode 100644 index 533da3233c..0000000000 --- a/.changeset/agent-manager-side-terminal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden. diff --git a/.changeset/agent-manager-terminal-shortcut-focus.md b/.changeset/agent-manager-terminal-shortcut-focus.md new file mode 100644 index 0000000000..8d0b718198 --- /dev/null +++ b/.changeset/agent-manager-terminal-shortcut-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused. diff --git a/.changeset/agent-manager-terminal-shortcut-platform.md b/.changeset/agent-manager-terminal-shortcut-platform.md new file mode 100644 index 0000000000..dbd2a126cb --- /dev/null +++ b/.changeset/agent-manager-terminal-shortcut-platform.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed. diff --git a/.changeset/atomic-session-revert.md b/.changeset/atomic-session-revert.md deleted file mode 100644 index ed922d1a6e..0000000000 --- a/.changeset/atomic-session-revert.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. diff --git a/.changeset/calm-cursors-fit.md b/.changeset/calm-cursors-fit.md new file mode 100644 index 0000000000..d35d8ee2d1 --- /dev/null +++ b/.changeset/calm-cursors-fit.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Agent Manager terminal cursor visible on the bottom row. diff --git a/.changeset/calm-run-terminals.md b/.changeset/calm-run-terminals.md new file mode 100644 index 0000000000..df2a4ace3d --- /dev/null +++ b/.changeset/calm-run-terminals.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow. diff --git a/.changeset/cli-startup-lazy-loading.md b/.changeset/cli-startup-lazy-loading.md new file mode 100644 index 0000000000..38571e5838 --- /dev/null +++ b/.changeset/cli-startup-lazy-loading.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-telemetry": patch +--- + +Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip diff --git a/.changeset/config-unset-propagation.md b/.changeset/config-unset-propagation.md new file mode 100644 index 0000000000..82418a5635 --- /dev/null +++ b/.changeset/config-unset-propagation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`) diff --git a/.changeset/console-headless-credentials.md b/.changeset/console-headless-credentials.md deleted file mode 100644 index 6c6102f467..0000000000 --- a/.changeset/console-headless-credentials.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console. \ No newline at end of file diff --git a/.changeset/enable-websearch-config.md b/.changeset/enable-websearch-config.md new file mode 100644 index 0000000000..cd47a9e9fb --- /dev/null +++ b/.changeset/enable-websearch-config.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings. diff --git a/.changeset/fix-nix-bun-pin.md b/.changeset/fix-nix-bun-pin.md deleted file mode 100644 index 5facef0667..0000000000 --- a/.changeset/fix-nix-bun-pin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep Nix builds on the Bun version required by the repository. diff --git a/.changeset/fix-scoped-mode-cycling.md b/.changeset/fix-scoped-mode-cycling.md deleted file mode 100644 index d71009746d..0000000000 --- a/.changeset/fix-scoped-mode-cycling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. diff --git a/.changeset/fix-vscode-settings-save.md b/.changeset/fix-vscode-settings-save.md deleted file mode 100644 index c7ed178ad9..0000000000 --- a/.changeset/fix-vscode-settings-save.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix settings changes sometimes failing to save and apply in VS Code. diff --git a/.changeset/fresh-rc-upgrades.md b/.changeset/fresh-rc-upgrades.md new file mode 100644 index 0000000000..61a54ea4e2 --- /dev/null +++ b/.changeset/fresh-rc-upgrades.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep RC installations up to date when a newer stable CLI release is published. diff --git a/.changeset/friendly-memories-rest.md b/.changeset/friendly-memories-rest.md new file mode 100644 index 0000000000..ff62c49064 --- /dev/null +++ b/.changeset/friendly-memories-rest.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-memory": patch +--- + +Accept memory digest responses that include additional model-generated fields. diff --git a/.changeset/fuzzy-otters-signal.md b/.changeset/fuzzy-otters-signal.md new file mode 100644 index 0000000000..eb802171c1 --- /dev/null +++ b/.changeset/fuzzy-otters-signal.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Report Bash commands terminated by a signal with the conventional 128 + signum exit code (e.g. 139 for SIGSEGV) instead of hanging until the command timeout. diff --git a/.changeset/fuzzy-pandas-listen.md b/.changeset/fuzzy-pandas-listen.md new file mode 100644 index 0000000000..c2d8334aec --- /dev/null +++ b/.changeset/fuzzy-pandas-listen.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Persist MCP server enable and disable changes from VS Code settings across window reloads. diff --git a/.changeset/fuzzy-tildes-smile.md b/.changeset/fuzzy-tildes-smile.md deleted file mode 100644 index 39c76209f8..0000000000 --- a/.changeset/fuzzy-tildes-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve parenthesized tilde expressions as literal text in rendered chat messages. diff --git a/.changeset/headless-run-honest-exit.md b/.changeset/headless-run-honest-exit.md new file mode 100644 index 0000000000..37d00a8493 --- /dev/null +++ b/.changeset/headless-run-honest-exit.md @@ -0,0 +1,13 @@ +--- +"@kilocode/cli": patch +--- + +Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain +headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI +auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause, +and a run whose session errors mid-stream now prints that diagnostic to stderr under +`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn +with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a +new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies +to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled); +interactive mode is untouched. diff --git a/.changeset/ingest-shutdown-flush.md b/.changeset/ingest-shutdown-flush.md deleted file mode 100644 index 94ff86a5b1..0000000000 --- a/.changeset/ingest-shutdown-flush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes. diff --git a/.changeset/instant-prompt-tooltips.md b/.changeset/instant-prompt-tooltips.md deleted file mode 100644 index d34f46dc04..0000000000 --- a/.changeset/instant-prompt-tooltips.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show prompt input toggle tooltips instantly on hover instead of after a delay. diff --git a/.changeset/jetbrains-bundled-cli.md b/.changeset/jetbrains-bundled-cli.md deleted file mode 100644 index d0239ff176..0000000000 --- a/.changeset/jetbrains-bundled-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": minor ---- - -Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation. diff --git a/.changeset/jetbrains-queued-prompts.md b/.changeset/jetbrains-queued-prompts.md deleted file mode 100644 index b22d8c8353..0000000000 --- a/.changeset/jetbrains-queued-prompts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Allow sending prompts while a session is busy and show queued prompts with a remove action. diff --git a/.changeset/long-session-prompt-navigation.md b/.changeset/long-session-prompt-navigation.md new file mode 100644 index 0000000000..967dde901f --- /dev/null +++ b/.changeset/long-session-prompt-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Navigate long conversations from a compact prompt rail that loads earlier history as you scroll. diff --git a/.changeset/opencode-v1-17-5-to-v1-17-9.md b/.changeset/opencode-v1-17-5-to-v1-17-9.md deleted file mode 100644 index 5a019637c3..0000000000 --- a/.changeset/opencode-v1-17-5-to-v1-17-9.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Changes from opencode v1.17.5 to v1.17.9 upstream: - -- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. -- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. -- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. -- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. -- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) -- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) -- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. -- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) -- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. -- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. -- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. -- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. -- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. -- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. -- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) -- Core Bugfixes: Pass configured custom headers to Copilot model requests. -- Core Improvements: MCP servers can now receive the current workspace as a client root. -- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. -- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) -- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. -- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. -- TUI Bugfixes: Only show the background subagent shortcut when the server supports it. -- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. diff --git a/.changeset/past-chats-worktree-family.md b/.changeset/past-chats-worktree-family.md new file mode 100644 index 0000000000..395bee2b5a --- /dev/null +++ b/.changeset/past-chats-worktree-family.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository. diff --git a/.changeset/persian-language.md b/.changeset/persian-language.md new file mode 100644 index 0000000000..9e07d9e8b8 --- /dev/null +++ b/.changeset/persian-language.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar. diff --git a/.changeset/pwsh-permission-fail-closed.md b/.changeset/pwsh-permission-fail-closed.md deleted file mode 100644 index cb140f33cd..0000000000 --- a/.changeset/pwsh-permission-fail-closed.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- `. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check. diff --git a/.changeset/quiet-json-events.md b/.changeset/quiet-json-events.md deleted file mode 100644 index bc7f513506..0000000000 --- a/.changeset/quiet-json-events.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Emit each agent event once from `kilo run --format json`. diff --git a/.changeset/quiet-terminals-switch.md b/.changeset/quiet-terminals-switch.md new file mode 100644 index 0000000000..0306eb3475 --- /dev/null +++ b/.changeset/quiet-terminals-switch.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open an embedded terminal automatically when switching to a worktree without one. diff --git a/.changeset/quiet-vscode-watchers.md b/.changeset/quiet-vscode-watchers.md deleted file mode 100644 index 0f44b7cfda..0000000000 --- a/.changeset/quiet-vscode-watchers.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. diff --git a/.changeset/reliable-vscode-message-copy.md b/.changeset/reliable-vscode-message-copy.md deleted file mode 100644 index 566918cf93..0000000000 --- a/.changeset/reliable-vscode-message-copy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep message and response copy buttons working after switching focus away from VS Code. diff --git a/.changeset/responses-stream-error-details.md b/.changeset/responses-stream-error-details.md new file mode 100644 index 0000000000..9b9d8cff06 --- /dev/null +++ b/.changeset/responses-stream-error-details.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show the provider's actual error message when an OpenAI or Azure Responses API stream fails (for example an upstream rate limit) instead of a generic retry notice. diff --git a/.changeset/ripgrep-error-detail.md b/.changeset/ripgrep-error-detail.md new file mode 100644 index 0000000000..6afe556432 --- /dev/null +++ b/.changeset/ripgrep-error-detail.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message. diff --git a/.changeset/safe-windows-snapshot-diffs.md b/.changeset/safe-windows-snapshot-diffs.md deleted file mode 100644 index 55dd9e61bc..0000000000 --- a/.changeset/safe-windows-snapshot-diffs.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. diff --git a/.changeset/side-terminal-tab-parity.md b/.changeset/side-terminal-tab-parity.md new file mode 100644 index 0000000000..715a1bfe21 --- /dev/null +++ b/.changeset/side-terminal-tab-parity.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel. diff --git a/.changeset/skill-shell-execution.md b/.changeset/skill-shell-execution.md new file mode 100644 index 0000000000..8689613673 --- /dev/null +++ b/.changeset/skill-shell-execution.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running. diff --git a/.changeset/smart-pandas-remain.md b/.changeset/smart-pandas-remain.md new file mode 100644 index 0000000000..cab4570c52 --- /dev/null +++ b/.changeset/smart-pandas-remain.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Run Agent Manager worktree setup scripts in the terminal selected by the toolbar dropdown. Agent Manager panel shows live setup output in a named `Setup` side tab that reveals itself while provisioning and hides again on success unless you engaged with the panel, while VS Code terminal retains the integrated task flow. diff --git a/.changeset/stalled-provider-first-byte.md b/.changeset/stalled-provider-first-byte.md deleted file mode 100644 index 2d11921fd6..0000000000 --- a/.changeset/stalled-provider-first-byte.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected. diff --git a/.changeset/steady-cli-subprocess-tests.md b/.changeset/steady-cli-subprocess-tests.md deleted file mode 100644 index e95a9d4c73..0000000000 --- a/.changeset/steady-cli-subprocess-tests.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Stabilize cross-platform CLI subprocess tests under constrained CI runners diff --git a/.changeset/steady-editor-tabs.md b/.changeset/steady-editor-tabs.md deleted file mode 100644 index 84e5f2b0b5..0000000000 --- a/.changeset/steady-editor-tabs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes. diff --git a/.changeset/tidy-am-i18n-keys.md b/.changeset/tidy-am-i18n-keys.md deleted file mode 100644 index 85c3ca1581..0000000000 --- a/.changeset/tidy-am-i18n-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys. diff --git a/.changeset/tidy-mice-report.md b/.changeset/tidy-mice-report.md new file mode 100644 index 0000000000..3ba5fbb6e9 --- /dev/null +++ b/.changeset/tidy-mice-report.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-telemetry": patch +--- + +Include the host operating system name, version, and architecture in telemetry events. diff --git a/.changeset/tui-variant-shortcut-hint.md b/.changeset/tui-variant-shortcut-hint.md deleted file mode 100644 index 8891876bdb..0000000000 --- a/.changeset/tui-variant-shortcut-hint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs index 51be1a3d8e..75ab641039 100644 --- a/.github/docs-sync/collect.mjs +++ b/.github/docs-sync/collect.mjs @@ -7,15 +7,20 @@ * * Pre-filter drops (triage never sees these): * - PRs labeled auto-docs (this bot's own rolling PRs) - * - chore/test/ci/build/docs/style/refactor/revert conventional titles + * - chore/test/ci/build/docs/style/refactor conventional titles * - PRs touching only docs/non-product paths * + * Revert PRs (conventional `revert(...):` and GitHub-native `Revert "..."` titles) + * are intercepted as signals, never added to the digest; affected digest entries + * gain `reverted_by` so triage/edit can skip them. + * * Bot-authored PRs are kept: release/dependency bots ship user-facing * changes too, and the label + docs-only guards above prevent loops. */ import fs from "node:fs" import { api, appendOutput, appendSummary, listPrFiles, searchIssues } from "./lib.mjs" +import { revertTitleKind, parseRevertTargets, applyRevertAnnotations, unannotatedRevertSignals } from "./reverts.mjs" const SOURCE_REPOS = ["Kilo-Org/cloud", "Kilo-Org/kilocode"] const OUT_DIR = "docs-sync-out" @@ -23,6 +28,7 @@ const BODY_LIMIT = 2000 const SLIM_BODY_LIMIT = 300 const PATCH_LIMIT = 8000 const FILE_LIMIT = 30 +// Revert PRs (conventional AND GitHub-native) are intercepted below BEFORE this filter; the "revert" alternative here is unreachable and kept only to minimize diff. const DROP_TITLE = /^(chore|test|ci|build|docs|style|refactor|revert)(\(.+\))?!?:/i const DOCS_ONLY_PATH = /^(packages\/kilo-docs\/|\.github\/docs-sync\/|docs-sync-out\/|docs\/|[^/]+\.md$)/ @@ -44,7 +50,8 @@ const since = argSince() console.log(`collecting PRs merged since ${since.toISOString()}`) const digest = [] -const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0 } +const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0, revert: 0 } +const reverts = [] for (const fullRepo of SOURCE_REPOS) { const prs = await mergedPrs(fullRepo, since) @@ -56,6 +63,22 @@ for (const fullRepo of SOURCE_REPOS) { dropped.label++ continue } + if (revertTitleKind(item.title ?? "")) { + try { + const pr = await api(`/repos/${fullRepo}/pulls/${item.number}`) + const targets = parseRevertTargets(pr.body ?? "", fullRepo) + reverts.push({ url: pr.html_url, merged_at: pr.merged_at, targets }) + if (targets.length === 0) { + console.warn(`::warning::revert PR ${fullRepo}#${item.number} has no parseable targets`) + } + dropped.revert++ + } catch (err) { + // One dead revert PR must not abort the run; its targets just go unannotated. + console.warn(`::warning::skipping revert ${fullRepo}#${item.number}: ${err.message}`) + dropped.fetch_error++ + } + continue + } if (DROP_TITLE.test(item.title ?? "")) { dropped.title++ continue @@ -108,6 +131,9 @@ for (const fullRepo of SOURCE_REPOS) { } } +const applied = applyRevertAnnotations(digest, reverts) +const unannotated = unannotatedRevertSignals(reverts, applied) + digest.sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at)) fs.mkdirSync(OUT_DIR, { recursive: true }) @@ -124,14 +150,31 @@ console.log(`kept ${digest.length} PRs, dropped:`, dropped) appendOutput("count", digest.length) appendOutput("digest", `${OUT_DIR}/digest.json`) -appendSummary( - [ - "### docs-sync collect", - "", - `- window: since \`${since.toISOString()}\``, - `- kept: **${digest.length}** PRs`, - `- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors`, - "", - ...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`), - ].join("\n"), -) +const summaryLines = [ + "### docs-sync collect", + "", + `- window: since \`${since.toISOString()}\``, + `- kept: **${digest.length}** PRs`, + `- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors, ${dropped.revert} reverts intercepted`, + "", + ...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`), +] +if (applied.length > 0) { + summaryLines.push("", "**revert annotations:**", ...applied.map(([target, reverter]) => `- ${target} — reverted by ${reverter}`)) +} +if (unannotated.missed.length > 0 || unannotated.unparsed.length > 0) { + summaryLines.push("", "**revert targets with no in-window annotation:**") + for (const m of unannotated.missed) { + summaryLines.push(`- ${m.url} — unannotated targets: ${m.targets.join(", ")}`) + } + for (const url of unannotated.unparsed) { + summaryLines.push(`- ${url} (no parseable targets)`) + } +} +if (unannotated.chains.length > 0) { + summaryLines.push("", "**revert chains (not annotated):**") + for (const c of unannotated.chains) { + summaryLines.push(`- ${c.url}${c.targets.length > 0 ? ` (targets: ${c.targets.join(", ")})` : ""}`) + } +} +appendSummary(summaryLines.join("\n")) diff --git a/.github/docs-sync/edit-prompt.md b/.github/docs-sync/edit-prompt.md index f0099e588f..c9a2b86abe 100644 --- a/.github/docs-sync/edit-prompt.md +++ b/.github/docs-sync/edit-prompt.md @@ -4,13 +4,16 @@ Before writing anything: 1. Read `packages/kilo-docs/AGENTS.md` and `packages/kilo-docs/STYLE_GUIDE.md` and follow them exactly: Markdoc custom tags, the `/docs` prefix in image paths, navigation files under `lib/nav/`, redirect rules, and the generated-screenshot policy. 2. Read the attached batch files: the full-details file (PR title, body, file list, `patch_excerpt` diffs) and the triage file (docs-worthiness verdicts, target sections, priorities). +3. Verify facts against the current source tree — for Kilo-Org/kilocode PRs. This checkout reflects current kilocode main: before documenting a command, flag, setting, default, or behavior from a kilocode PR, confirm it exists in the current source. Existence alone is not enough: defaults, whether an option is required or optional, and on-by-default behavior must also match the current tree — a symbol that still exists as opt-in does not justify documenting default-on behavior. When the merged diff and the current tree disagree, the current tree wins — the change may have been reverted or superseded; skip it and record why. For Kilo-Org/cloud PRs the source is not in this checkout: rely on the PR diff and body, and on the `reverted_by` field below. Reading any file in the checkout for verification is expected; the hard rule against touching anything outside `packages/kilo-docs/` applies to writing only. -For each PR in the batch, in priority order: +For each PR in the batch, in priority order, first decide whether it needs documentation at all — skipping is a first-class outcome. For each one that does: - Find the most relevant existing docs page(s) and make minimal, precise updates in the style of the surrounding content. - Create a new page only when no existing page fits; then add it to the matching nav file in `packages/kilo-docs/lib/nav/`. -- Document only behavior that is actually present in the merged diff. If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why. +- Document only behavior that is actually present in the merged diff and still present in the product now (step 3 above: when they disagree, the current state wins). If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why. - If a PR turns out not to need documentation, skip it and record why. Trust evidence over the triage verdict. +- A batch PR with a `reverted_by` field was reverted by that PR; skip it unless there is clear evidence the change is present now — for kilocode PRs verify it in the current source tree (re-land); for cloud PRs require explicit re-land evidence in the entry itself. +- Skipping is a normal outcome: a batch where every PR is skipped is a valid result. Never write docs just to have something to show. Hard rules: diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 777c3a2929..167656eda3 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -82,8 +82,13 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} break } + // Headless `kilo run` auto-rejects every permission ask; without --auto the + // agent cannot run shell commands. SECURITY: --auto grants unrestricted bash + // to an agent steered by external PR content. Hardening deferred: a scoped + // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the + // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], + args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], timeoutMs: Math.min(BATCH_TIMEOUT_MS, left), streamStdout: true, label: `edit batch ${index} attempt ${attempt}`, diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 67c13354d8..6aaf9dc6c3 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -167,13 +167,53 @@ export function sleepSync(ms) { const STDERR_TAIL_LINES = 20 const STDERR_TAIL_CHARS = 4_000 +// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr, +// so an unstripped tail lands in the rolling PR's pending table as +// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before +// the line/char slice so escapes do not eat the budget. The persisted +// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record. +// eslint-disable-next-line no-control-regex +const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g + function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) { - const s = String(text ?? "").trim() + const s = String(text ?? "") + .replace(ANSI_CSI, "") + .trim() if (!s) return "" const lastLines = s.split("\n").slice(-lines).join("\n") return lastLines.length > chars ? lastLines.slice(-chars) : lastLines } +/** + * Artifact files are raw: GitHub masks secret values in log streams only, and the runner + * env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars + * are redacted before stdout/stderr is persisted or printed. + * Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or + * line-wrapped renderings and values shorter than 8 chars survive (same limitation as + * GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean. + */ +export function redactEnvSecrets(text) { + let out = String(text ?? "") + // Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET. + const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i + const candidates = [] + for (const [name, value] of Object.entries(process.env)) { + if (!nameRe.test(name)) continue + if (typeof value !== "string" || value.length < 8) continue + candidates.push(value) + } + // Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder. + candidates.sort((a, b) => b.length - a.length) + for (const value of candidates) { + if (!out.includes(value)) continue + out = out.split(value).join("***") + } + return out +} + +/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */ +const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024 + /** * Run `kilo` via spawnSync so stderr is always recoverable — including when * the child exits 0 after writing a diagnostic (execFileSync cannot return @@ -181,6 +221,10 @@ function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } * * streamStdout:true → inherit fd 1 (edit live log); false → capture stdout * (triage parses it). stderr is always buffered. + * + * Always writes the full captured stderr to + * docs-sync-out/kilo-stderr-.log (unconditional — success and + * failure). The console return value still uses the short tailText. */ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) { const result = spawnSync("kilo", args, { @@ -193,16 +237,30 @@ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT") const exitCode = typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status - const stderrTail = tailText(result.stderr) - const stdout = streamStdout ? "" : String(result.stdout ?? "") + const stderrRaw = String(result.stderr ?? "") + const stderrSafe = redactEnvSecrets(stderrRaw) + const stderrTail = tailText(stderrSafe) + const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? "")) // ok is "process finished without OS-level failure". Callers still treat a // missing summary / unparseable output as failure even when ok is true — // exit 0 is not success for the docs-sync bot. const ok = !result.error && result.status === 0 + // Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is + // generous (megabytes) so long batch dumps keep auto-rejecting lines; console + // still uses the short tail above. + try { + fs.mkdirSync("docs-sync-out", { recursive: true }) + const safe = label.replace(/[^A-Za-z0-9._-]/g, "-") + const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe + fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body) + } catch (err) { + console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`) + } + if (result.error && !timedOut) { console.warn(`${label}: spawn error: ${result.error.message}`) } - return { ok, stdout, stderrTail, exitCode, timedOut } + return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut } } diff --git a/.github/docs-sync/redact-stream.mjs b/.github/docs-sync/redact-stream.mjs new file mode 100644 index 0000000000..341aea34ad --- /dev/null +++ b/.github/docs-sync/redact-stream.mjs @@ -0,0 +1,25 @@ +// kilocode_change - new file + +/** + * Line-wise stdin→stdout filter that redacts secret-looking env values. + * Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe. + * Env values contain no newlines, so line-wise processing never splits a value. + */ + +import { redactEnvSecrets } from "./lib.mjs" + +let carry = "" + +process.stdin.setEncoding("utf8") +process.stdin.on("data", (chunk) => { + carry += chunk + let idx + while ((idx = carry.indexOf("\n")) !== -1) { + const line = carry.slice(0, idx + 1) + carry = carry.slice(idx + 1) + process.stdout.write(redactEnvSecrets(line)) + } +}) +process.stdin.on("end", () => { + if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry)) +}) diff --git a/.github/docs-sync/reverts.mjs b/.github/docs-sync/reverts.mjs new file mode 100644 index 0000000000..5702fa3743 --- /dev/null +++ b/.github/docs-sync/reverts.mjs @@ -0,0 +1,153 @@ +// kilocode_change - new file + +/** + * Pure helpers for intercepting revert PRs during docs-sync collect. + * No I/O, no imports — offline-testable title/body/annotation logic only. + */ + +/** Detect revert PR titles. Returns "conventional" | "github-native" | null. */ +export function revertTitleKind(title) { + const t = String(title ?? "") + if (/^revert(\(.+\))?!?:/i.test(t)) return "conventional" + if (/^revert\s+["']/i.test(t)) return "github-native" + return null +} + +/** + * Parse revert targets from a PR body. `defaultRepo` ("Kilo-Org/kilocode") resolves bare `#N`. + * Returns [{ repo, number, url }] with url = `https://github.com/${repo}/pull/${number}`. + * Handles the conventional trailer form: a line starting with `Reverts` (case-insensitive), + * e.g. `Reverts #12249 and #12481.`, `Reverts Kilo-Org/cloud#42.`, comma-separated lists, + * several such lines in one body, and bulleted/quoted single-line trailers + * (`- Reverts #12249`, `> Reverts #12249`). + * NOT handled (documented limitation): `This reverts commit .` (no PR number), + * mid-sentence forms (`This reverts #5.`), narrative mentions (`Revert the fix in #99999` + * is prose, not a trailer — must NOT produce a target), `Revertsomething #5` (prose glued + * to the word — `\b` word boundary makes it inert), and multi-line lists + * (`Reverts:\n- #1\n- #2`). + */ +export function parseRevertTargets(body, defaultRepo) { + const text = String(body ?? "") + const repoDefault = String(defaultRepo ?? "") + const seen = new Set() + const out = [] + const lineRe = /^[ \t>*-]*reverts\b[ \t:]*([^\n]*)/gim + let lineMatch + while ((lineMatch = lineRe.exec(text)) !== null) { + const capture = lineMatch[1] ?? "" + const refRe = /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)/g + let refMatch + while ((refMatch = refRe.exec(capture)) !== null) { + const repo = refMatch[1] || repoDefault + const number = Number(refMatch[2]) + if (!repo || !Number.isInteger(number)) continue + const url = `https://github.com/${repo}/pull/${number}` + if (seen.has(url)) continue + seen.add(url) + out.push({ repo, number, url }) + } + } + return out +} + +/** + * revertSignals: [{ url, merged_at, targets: [{ repo, number, url }] }] + * Returns Map — the annotation for each reverted PR. + * Map keys are lowercased target urls; values keep signal.url as authored. + * Revert-of-revert: a signal whose own url is itself a target of another signal is + * dropped entirely (a re-land: net effect zero — one set lookup, no chain walking). + */ +export function computeRevertAnnotations(revertSignals) { + const signals = Array.isArray(revertSignals) ? revertSignals : [] + const revertedUrls = new Set() + for (const signal of signals) { + for (const t of signal?.targets ?? []) { + if (t?.url) revertedUrls.add(t.url.toLowerCase()) + } + } + const annotations = new Map() + for (const signal of signals) { + if (!signal?.url || revertedUrls.has(signal.url.toLowerCase())) continue + for (const t of signal.targets ?? []) { + if (!t?.url) continue + annotations.set(t.url.toLowerCase(), { url: signal.url, merged_at: signal.merged_at }) + } + } + return annotations +} + +/** + * Applies computeRevertAnnotations to digest entries in place: an entry whose url + * was reverted gains `reverted_by: { url, merged_at }` (the reverter). + * Lookup is case-insensitive; applied pairs still report entry.url (canonical). + * Returns applied [targetUrl, reverterUrl] pairs (digest entries only) for reporting. + */ +export function applyRevertAnnotations(digest, revertSignals) { + const annotations = computeRevertAnnotations(revertSignals) + const applied = [] + for (const entry of digest ?? []) { + if (!entry?.url) continue + const ann = annotations.get(entry.url.toLowerCase()) + if (!ann) continue + entry.reverted_by = { url: ann.url, merged_at: ann.merged_at } + applied.push([entry.url, ann.url]) + } + return applied +} + +/** + * Reports intercepted reverts whose targets received no digest annotation, for the + * step summary. `signals` is the same shape as computeRevertAnnotations takes; + * `appliedPairs` is applyRevertAnnotations' return ([targetUrl, reverterUrl]). + * Returns { missed, unparsed, chains }: + * - missed: [{ url, targets: [targetUrl, ...] }] — one entry per signal with at least + * one unannotated plain target, listing exactly the targets that missed (a partially-covered + * revert still surfaces its uncovered targets). Plain = not itself an intercepted revert. + * - unparsed: [url, ...] — urls of signals with zero targets (they already warn at + * intercept time). + * - chains: [{ url, targets: [targetUrl, ...] }] — signals involved in a revert chain, + * reported for human visibility only; the pipeline never resolves a chain's net effect. + * A signal lands here when EITHER its own url is a target of another signal (cancelled + * by computeRevertAnnotations — lists ALL its targets), OR at least one of its targets + * is itself an intercepted revert (re-land chain link — lists ONLY those chain-link + * targets; plain targets still flow through missed). + * Cancelled signals skip missed/unparsed. Chain-linked (non-cancelled) signals may also + * appear in missed for their unannotated plain targets. Chain-link targets never appear + * in missed. All url comparisons are case-insensitive. + */ +export function unannotatedRevertSignals(signals, appliedPairs) { + const list = Array.isArray(signals) ? signals : [] + const annotated = new Set((appliedPairs ?? []).map(([target]) => String(target ?? "").toLowerCase())) + const signalUrls = new Set(list.map((s) => String(s?.url ?? "").toLowerCase())) + const cancelledUrls = new Set() + for (const s of list) { + for (const t of s?.targets ?? []) { + if (t?.url) cancelledUrls.add(t.url.toLowerCase()) + } + } + const missed = [] + const unparsed = [] + const chains = [] + for (const s of list) { + const url = s?.url + if (!url) continue + const targets = (s.targets ?? []).map((t) => t?.url).filter(Boolean) + const cancelled = cancelledUrls.has(url.toLowerCase()) + const chainTargets = targets.filter((u) => signalUrls.has(u.toLowerCase())) + if (cancelled) { + chains.push({ url, targets }) + continue + } + if (chainTargets.length > 0) { + chains.push({ url, targets: chainTargets }) + } + if (targets.length === 0) { + unparsed.push(url) + continue + } + const plainTargets = targets.filter((u) => !signalUrls.has(u.toLowerCase())) + const missing = plainTargets.filter((u) => !annotated.has(u.toLowerCase())) + if (missing.length > 0) missed.push({ url, targets: missing }) + } + return { missed, unparsed, chains } +} diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index fe2cb1faec..9af35c58cd 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -24,6 +24,13 @@ import { renderBody, extractSectionRows, } from "./upsert-pr.mjs" +import { + revertTitleKind, + parseRevertTargets, + computeRevertAnnotations, + applyRevertAnnotations, + unannotatedRevertSignals, +} from "./reverts.mjs" const HERE = path.dirname(fileURLToPath(import.meta.url)) const EDIT_SCRIPT = path.join(HERE, "edit.mjs") @@ -55,7 +62,7 @@ function writeExecutable(filePath, body) { function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) { const dir = mktemp("docs-sync-kilo-") const kiloPath = path.join(dir, "kilo") - // mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" + // mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" | "write-edit-summary" const script = `#!/usr/bin/env node const fs = require("node:fs"); const path = require("node:path"); @@ -81,6 +88,22 @@ let chunk = []; if (fileArg && fs.existsSync(fileArg)) { try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; } } +if (mode === "write-edit-summary") { + // Success path: write the batch summary so edit.mjs returns true, while still + // emitting stderr so selftest can assert runKilo persisted it unconditionally. + process.stderr.write(stderrText + "\\n"); + const m = fileArg && String(fileArg).match(/edit-batch-(\\d+)\\.json/); + const index = m ? m[1] : "0"; + const summary = chunk.map((d) => ({ + pr: d.number, + url: d.url, + action: "skipped", + reason: "selftest stub", + })); + fs.mkdirSync("docs-sync-out", { recursive: true }); + fs.writeFileSync("docs-sync-out/edit-summary-" + index + ".json", JSON.stringify(summary)); + process.exit(0); +} if (mode === "partial-triage") { // Classify only a proper subset (first URL) of the chunk. const owned = chunk.slice(0, Math.max(0, chunk.length - 1)); @@ -116,6 +139,21 @@ if (mode === "mixed-triage") { process.stdout.write(JSON.stringify(entries) + "\\n"); process.exit(0); } +if (mode === "triage-embed-env-secret") { + // Valid triage JSON with a secret env value embedded in a string field + // (stdout is persisted to triage-raw-*.txt; must be redacted at capture). + const secret = process.env.KILO_API_KEY || "missing-secret"; + const entries = chunk.map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: true, + reason: "needs docs; diagnostic=" + secret, + target_sections: ["overview"], + priority: "high", + })); + process.stdout.write(JSON.stringify(entries) + "\\n"); + process.exit(0); +} process.stderr.write("unknown stub mode\\n"); process.exit(1); ` @@ -129,7 +167,9 @@ function gitIn(cwd, args, env = {}) { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", - }).toString().trim() + }) + .toString() + .trim() } function makeGitRunner(cwd, env = {}) { @@ -247,14 +287,19 @@ function case1_mergeOrFallback() { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", - }).toString().trim() + }) + .toString() + .trim() - assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => { - // Must throw the original merge error, not a merge --abort failure - const msg = String(err?.stderr ?? err?.message ?? err) - assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`) - return true - }) + assert.throws( + () => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), + (err) => { + // Must throw the original merge error, not a merge --abort failure + const msg = String(err?.stderr ?? err?.message ?? err) + assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`) + return true + }, + ) } } @@ -372,6 +417,450 @@ function case2_defectB() { } } +// --------------------------------------------------------------------------- +// Case 2b — AC4a: every docs-sync kilo run argv carries --auto +// --------------------------------------------------------------------------- +/** Slice `args: [` … matching `]` from source (newlines allowed inside). */ +function extractArgsArraySlice(source) { + const start = source.indexOf("args: [") + assert.ok(start >= 0, "args: [ not found in source") + let i = start + "args: ".length + assert.equal(source[i], "[") + let depth = 0 + for (; i < source.length; i++) { + const ch = source[i] + if (ch === "[") depth++ + else if (ch === "]") { + depth-- + if (depth === 0) return source.slice(start, i + 1) + } + } + throw new assert.AssertionError({ message: "unclosed args: [ array in source" }) +} + +/** Label → kilo-stderr filename rule (must match lib.mjs runKilo). */ +function kiloStderrLogName(label) { + return `kilo-stderr-${String(label).replace(/[^A-Za-z0-9._-]/g, "-")}.log` +} + +function case2b_autoFlag() { + console.log("case 2b: AC4a (--auto on every docs-sync kilo run)") + + // (i) region-scoped static check on triage.mjs / edit.mjs argv arrays + for (const name of ["triage.mjs", "edit.mjs"]) { + const src = fs.readFileSync(path.join(HERE, name), "utf8") + const slice = extractArgsArraySlice(src) + assert.ok(slice.includes('"--auto"'), `${name} args array must contain "--auto"; got:\n${slice}`) + } + + // (ii) Fix verify failures step: join the run: | block and require --auto on kilo run + { + const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8") + const stepIdx = yml.indexOf("Fix verify failures") + assert.ok(stepIdx >= 0, "Fix verify failures step missing") + const afterStep = yml.slice(stepIdx) + const runIdx = afterStep.indexOf("run: |") + assert.ok(runIdx >= 0, "run: | missing after Fix verify failures") + const blockStart = stepIdx + runIdx + "run: |".length + const rest = yml.slice(blockStart) + // Block ends at next unindented step key or EOF — collect indented lines + const lines = [] + for (const line of rest.split("\n")) { + if (line === "") { + lines.push(line) + continue + } + // stop at next top-level list item under steps (two-space + "- ") + if (/^ {0,6}- name:/.test(line) || (/^\S/.test(line) && lines.length > 0)) break + lines.push(line) + } + // Join continuation backslashes then collapse whitespace for the kilo run line + const joined = lines + .map((l) => l.replace(/^\s+/, "")) + .join("\n") + .replace(/\\\n/g, " ") + .replace(/\s+/g, " ") + assert.match(joined, /kilo run\b/, `expected kilo run in Fix verify block:\n${joined}`) + const kiloCmd = joined.match(/kilo run\b[^|]*/)?.[0] ?? "" + assert.ok( + /\s--auto\b/.test(kiloCmd) || /kilo run\s+--auto\b/.test(kiloCmd), + `Fix verify kilo run must contain --auto; got: ${kiloCmd}`, + ) + + // The step runs under `set -o pipefail` + the default `bash -e`, so an + // unguarded kilo pipeline aborts the block before verify2.log is written + // once the CLI exits nonzero on a mid-stream error. The rebuild must decide + // this step's outcome, not the agent's exit code. + // Window is the end of the kilo pipeline → the rebuild, so a comment + // elsewhere in the block cannot satisfy the guard assertion. + const teeIdx = joined.indexOf("tee -a docs-sync-out/edit-log.txt") + assert.ok(teeIdx >= 0, `expected the kilo pipeline to tee edit-log.txt:\n${joined}`) + const kiloPipeline = joined.slice(teeIdx, joined.indexOf("bun run", teeIdx)) + assert.match( + kiloPipeline, + /\|\|\s*(echo|true)\b/, + `Fix verify kilo pipeline must be guarded (|| echo/true) so bash -e cannot skip the rebuild; got: ${kiloPipeline}`, + ) + assert.match(joined, /verify2\.log/, "Fix verify block must still write verify2.log") + } + + // (iii) authoritative: real stub invocations with callLog — every argv has --auto + { + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const callLog = path.join(cwd, "kilo-calls.log") + const stderrText = "event stream disconnected DIAG-AUTO" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText, callLog }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + assert.ok(fs.existsSync(callLog), "callLog must be written (stub was invoked)") + const lines = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean) + assert.ok(lines.length > 0, "callCount > 0 required (vacuous empty log forbidden)") + for (const line of lines) { + const { argv } = JSON.parse(line) + assert.ok( + Array.isArray(argv) && argv.includes("--auto"), + `every kilo argv must include --auto; got ${JSON.stringify(argv)}`, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Case 2c — full child stderr always written (success and failure paths) +// --------------------------------------------------------------------------- +function case2c_stderrLogAlways() { + console.log("case 2c: unconditional kilo-stderr-*.log") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + + // Failure path: stub exits 0 without summary (same mode as case 2) + { + const cwd = setupEditCwd(worthy, triage) + const stderrText = "FAILPATH-STDERR-MARKER" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, result.output) + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.equal(logName, "kilo-stderr-edit-batch-0-attempt-1.log") + assert.ok(fs.existsSync(logPath), `expected ${logPath} on failure path`) + assert.match(fs.readFileSync(logPath, "utf8"), /FAILPATH-STDERR-MARKER/) + } + + // Success path: stub writes summary (today's path that discarded stderr) + { + const cwd = setupEditCwd(worthy, triage) + const stderrText = "SUCCESSPATH-STDERR-MARKER" + const kiloDir = makeStubKiloDir({ mode: "write-edit-summary", stderrText }) + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, result.output) + assert.ok( + fs.existsSync(path.join(cwd, "docs-sync-out", "edit-summary-0.json")), + "stub must write summary (success path)", + ) + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.ok(fs.existsSync(logPath), `expected ${logPath} on success path`) + assert.match(fs.readFileSync(logPath, "utf8"), /SUCCESSPATH-STDERR-MARKER/) + } +} + +// --------------------------------------------------------------------------- +// Case 2d — redact secret env values from captured kilo stderr (artifact-safe) +// --------------------------------------------------------------------------- +function case2d_redactEnvSecrets() { + console.log("case 2d: redact env secrets from kilo stderr capture") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const secret = "selftest-secret-value-12345" + const stderrText = `leak before ${secret} after` + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + KILO_API_KEY: secret, + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.ok(fs.existsSync(logPath), `expected ${logPath}`) + const logBody = fs.readFileSync(logPath, "utf8") + assert.ok(!logBody.includes(secret), `persisted stderr must not contain secret; got: ${logBody}`) + assert.ok(logBody.includes("leak before *** after"), `persisted stderr must redact to exact line; got: ${logBody}`) + + // Console stderr-tail region must also be redacted (not only the artifact file). + const tailIdx = result.output.indexOf("stderr tail:") + assert.ok(tailIdx >= 0, `expected stderr tail: in output; got: ${result.output}`) + const tailRegion = result.output.slice(tailIdx) + assert.ok(!tailRegion.includes(secret), `console stderr tail must not contain secret; got: ${tailRegion}`) +} + +// --------------------------------------------------------------------------- +// Case 2e — longer secret first when a shorter env value is a prefix +// --------------------------------------------------------------------------- +function case2e_prefixSecretOrdering() { + console.log("case 2e: prefix-secret ordering (longer value redacted first)") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const shortSecret = "abcdefgh" + const longSecret = "abcdefghIJKL-tail" + const stderrText = `leak: ${longSecret} end` + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + A_KEY: shortSecret, + B_TOKEN: longSecret, + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + const logName = kiloStderrLogName("edit batch 0 attempt 1") + const logPath = path.join(cwd, "docs-sync-out", logName) + assert.ok(fs.existsSync(logPath), `expected ${logPath}`) + const logBody = fs.readFileSync(logPath, "utf8") + assert.ok(!logBody.includes("IJKL-tail"), `must not leak prefix remainder; got: ${logBody}`) + assert.ok(logBody.includes("leak: *** end"), `expected full long secret redacted; got: ${logBody}`) +} + +// --------------------------------------------------------------------------- +// Case 2f — redact secret values from captured kilo stdout (triage-raw artifact) +// --------------------------------------------------------------------------- +function case2f_redactStdout() { + console.log("case 2f: redact env secrets from kilo stdout (triage-raw)") + + const digest = [samplePr(501), samplePr(502)] + const cwd = setupTriageCwd(digest) + const secret = "selftest-stdout-secret-99999" + const kiloDir = makeStubKiloDir({ mode: "triage-embed-env-secret" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + GITHUB_STEP_SUMMARY: summaryFile, + KILO_API_KEY: secret, + }, + }) + assert.equal(result.status, 0, `triage.mjs exit: ${result.output}`) + + const rawFiles = fs.readdirSync(path.join(cwd, "docs-sync-out")).filter((f) => f.startsWith("triage-raw-")) + assert.ok(rawFiles.length > 0, "expected triage-raw-*.txt artifact") + for (const f of rawFiles) { + const body = fs.readFileSync(path.join(cwd, "docs-sync-out", f), "utf8") + assert.ok(!body.includes(secret), `triage-raw must not contain secret; ${f}: ${body}`) + } + + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.ok(triage.length >= 1, "triage must still parse after redaction") + assert.ok( + triage.some((e) => e.docs_worthy === true || e.pending === true || e.docs_worthy === false), + "triage entries must be structured", + ) +} + +// --------------------------------------------------------------------------- +// Case 2g — redact-stream.mjs line-wise filter (including partial last line) +// --------------------------------------------------------------------------- +function case2g_redactStream() { + console.log("case 2g: redact-stream.mjs stdin filter") + + const secret = "stream-secret-value-xyz" + const filterPath = path.join(HERE, "redact-stream.mjs") + assert.ok(fs.existsSync(filterPath), `expected ${filterPath}`) + + const input = `leak ${secret} after\npartial-${secret}` + const result = spawnSync(process.execPath, [filterPath], { + env: { ...process.env, KILO_API_KEY: secret }, + input, + encoding: "utf8", + timeout: 10_000, + }) + assert.equal(result.status, 0, `redact-stream exit: ${result.stderr || result.error}`) + assert.equal(result.stdout, "leak *** after\npartial-***") +} + +// --------------------------------------------------------------------------- +// Case 2h — pending causes reach the rolling PR free of ANSI escapes +// --------------------------------------------------------------------------- +function case2h_pendingCauseIsReadable() { + console.log("case 2h: pending cause has no ANSI escapes") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + // Verbatim shape of a real kilo TUI stderr line (see PR #12521's pending table). + const ESC = "\u001b" + const stderrText = `${ESC}[0m→ ${ESC}[0mRead packages/kilo-docs/AGENTS.md${ESC}[2K${ESC}[1G done` + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.equal(summary.length, 5) + for (const e of summary) { + assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`) + assert.ok(!e.reason.includes(ESC), `pending reason must not contain ANSI escapes: ${JSON.stringify(e.reason)}`) + // Non-vacuous: the diagnostic text itself must survive the strip. + assert.match(e.reason, /Read packages\/kilo-docs\/AGENTS\.md/) + } + + // The raw artifact log keeps the escapes — it is the debugging record. + const rawLog = fs.readFileSync(path.join(cwd, "docs-sync-out", "kilo-stderr-edit-batch-0-attempt-1.log"), "utf8") + assert.ok(rawLog.includes(ESC), "persisted stderr log must stay raw") +} + +// --------------------------------------------------------------------------- +// Case 2i — wall-clock budgets can actually fit work +// --------------------------------------------------------------------------- +/** + * The pre-unit gates in triage.mjs/edit.mjs refuse to start a chunk/batch unless + * a whole per-unit timeout remains, so a budget below that timeout silently runs + * ZERO units and defers every PR. Run 30306629290 hit the weaker form of this: + * 8 of 11 chunks and 4 of 11 batches ran, the rest deferred untried. Assert the + * workflow sets both budgets and that each fits at least two units. + */ +function case2i_budgetsFitWork() { + console.log("case 2i: triage/edit budgets fit at least two units") + + const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8") + const readEnvNumber = (key) => { + const m = yml.match(new RegExp(`^\\s*${key}:\\s*"?(\\d+)"?\\s*$`, "m")) + assert.ok(m, `${key} must be set in docs-sync.yml (default is too small to drain a backlog)`) + return Number(m[1]) + } + + // Per-unit timeouts are script constants, not workflow env; read them from source. + const triageSrc = fs.readFileSync(path.join(HERE, "triage.mjs"), "utf8") + const chunkMin = Number(triageSrc.match(/CHUNK_TIMEOUT_MS = (\d+) \* 60 \* 1000/)?.[1]) + assert.ok(Number.isFinite(chunkMin), "could not read CHUNK_TIMEOUT_MS from triage.mjs") + + const editSrc = fs.readFileSync(path.join(HERE, "edit.mjs"), "utf8") + const batchMin = Number(editSrc.match(/EDIT_BATCH_TIMEOUT_MINUTES\) \|\| (\d+)/)?.[1]) + assert.ok(Number.isFinite(batchMin), "could not read EDIT_BATCH_TIMEOUT_MINUTES default from edit.mjs") + + const triageBudget = readEnvNumber("TRIAGE_BUDGET_MINUTES") + const editBudget = readEnvNumber("EDIT_BUDGET_MINUTES") + assert.ok( + triageBudget >= 2 * chunkMin, + `TRIAGE_BUDGET_MINUTES=${triageBudget} must be >= 2x chunk timeout (${chunkMin}m)`, + ) + assert.ok(editBudget >= 2 * batchMin, `EDIT_BUDGET_MINUTES=${editBudget} must be >= 2x batch timeout (${batchMin}m)`) + + // The job timeout must outlast both budgets plus the non-LLM steps. + const jobTimeout = Number(yml.match(/^\s*timeout-minutes:\s*(\d+)\s*$/m)?.[1]) + assert.ok(Number.isFinite(jobTimeout), "could not read job timeout-minutes") + assert.ok( + jobTimeout > triageBudget + editBudget, + `job timeout-minutes=${jobTimeout} must exceed triage+edit budgets (${triageBudget}+${editBudget})`, + ) +} + // --------------------------------------------------------------------------- // Case 3 — watermark invariant // --------------------------------------------------------------------------- @@ -611,14 +1100,8 @@ function case4_routing() { triage: [], uncovered: [], }) - assert.ok( - !forgedRows.skippedRows[0].includes(""), - "clean() must strip --> from reasons", - ) + assert.ok(!forgedRows.skippedRows[0].includes(""), "clean() must strip --> from reasons") const forgedBody = renderBody({ date: "2026-07-27", since: "s", @@ -927,6 +1410,420 @@ function case8_triage() { } } +// --------------------------------------------------------------------------- +// Case 9 — revert interception (title/body/annotations + source-order guards) +// --------------------------------------------------------------------------- +function case9_reverts() { + console.log("case 9: revert interception") + + // --- revertTitleKind --- + assert.equal(revertTitleKind('revert(cli): restore opt-in stream idle timeouts'), "conventional") + assert.equal(revertTitleKind('Revert "feat(cli): default stream watchdog"'), "github-native") + assert.equal(revertTitleKind("REVERT: all of it"), "conventional") + assert.equal(revertTitleKind("feat(cli): add x"), null) + assert.equal(revertTitleKind("docs: update y"), null) + assert.equal(revertTitleKind("Reverted behavior docs"), null) + + // --- parseRevertTargets --- + const defaultRepo = "Kilo-Org/kilocode" + const body12497 = `The default stream inactivity watchdog introduced by #12249 aborts requests based only on the absence of normalized AI SDK events. That signal cannot distinguish a dead provider stream from long prompt processing, reasoning, buffering, or transport behavior, and the follow-up in #12481 reduces false positives without resolving that ambiguity. + +Revert both changes and restore the previous opt-in contract: Kilo does not impose a stream idle timeout unless the provider configuration explicitly sets \`chunkTimeout\`. Explicit provider timeouts continue to use the existing AI SDK and SSE timeout paths. This removes the global heuristic while the underlying stalled-stream source and the required transport-level observability are investigated. + +This deliberately restores the possibility that an unconfigured provider stream can remain open indefinitely. A default watchdog should be reintroduced only with evidence that its liveness signal and threshold do not terminate healthy responses. + +Reverts #12249 and #12481. +` + { + const targets = parseRevertTargets(body12497, defaultRepo) + assert.equal(targets.length, 2) + assert.deepEqual( + targets.map((t) => ({ repo: t.repo, number: t.number, url: t.url })), + [ + { + repo: "Kilo-Org/kilocode", + number: 12249, + url: "https://github.com/Kilo-Org/kilocode/pull/12249", + }, + { + repo: "Kilo-Org/kilocode", + number: 12481, + url: "https://github.com/Kilo-Org/kilocode/pull/12481", + }, + ], + ) + } + + { + const narrative = "Revert the mistaken fix in #99999 because it broke streams.\n\nReverts #12249." + const targets = parseRevertTargets(narrative, defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].number, 12249) + assert.ok(!targets.some((t) => t.number === 99999)) + } + + { + const targets = parseRevertTargets("Reverts Kilo-Org/cloud#42.", defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].repo, "Kilo-Org/cloud") + assert.equal(targets[0].number, 42) + assert.equal(targets[0].url, "https://github.com/Kilo-Org/cloud/pull/42") + } + + { + const targets = parseRevertTargets("Reverts #1, #2.", defaultRepo) + assert.equal(targets.length, 2) + assert.deepEqual( + targets.map((t) => t.number), + [1, 2], + ) + } + + assert.deepEqual(parseRevertTargets("This reverts commit deadbeefcafe.", defaultRepo), []) + assert.deepEqual(parseRevertTargets("This reverts #5.", defaultRepo), []) + assert.deepEqual(parseRevertTargets("No revert trailer here at all.", defaultRepo), []) + + // bulleted / quoted single-line trailers + { + const targets = parseRevertTargets("- Reverts #7.", defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].number, 7) + } + { + const targets = parseRevertTargets("> Reverts #8.", defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].number, 8) + } + // word-boundary: prose glued to "Reverts" is not a trailer + assert.deepEqual(parseRevertTargets("Revertsomething #5", defaultRepo), []) + + // --- computeRevertAnnotations --- + // Map keys are lowercased; lookups must use .toLowerCase() + const fUrl = "https://github.com/Kilo-Org/kilocode/pull/100" + const r1Url = "https://github.com/Kilo-Org/kilocode/pull/200" + const r2Url = "https://github.com/Kilo-Org/kilocode/pull/300" + const f2Url = "https://github.com/Kilo-Org/kilocode/pull/101" + const mergedAt = "2026-07-20T12:00:00.000Z" + const mergedAt2 = "2026-07-21T12:00:00.000Z" + + { + const annotations = computeRevertAnnotations([ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + ]) + assert.equal(annotations.size, 1) + assert.deepEqual(annotations.get(fUrl.toLowerCase()), { url: r1Url, merged_at: mergedAt }) + } + + { + // two-target signal (#12497-shaped) + const annotations = computeRevertAnnotations([ + { + url: r1Url, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 100, url: fUrl }, + { repo: "Kilo-Org/kilocode", number: 101, url: f2Url }, + ], + }, + ]) + assert.equal(annotations.size, 2) + assert.deepEqual(annotations.get(fUrl.toLowerCase()), { url: r1Url, merged_at: mergedAt }) + assert.deepEqual(annotations.get(f2Url.toLowerCase()), { url: r1Url, merged_at: mergedAt }) + } + + { + // revert-of-revert: R1 reverts F, R2 reverts R1 → F not annotated; R1 gets R2 + const annotations = computeRevertAnnotations([ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + ]) + assert.equal(annotations.has(fUrl.toLowerCase()), false) + assert.deepEqual(annotations.get(r1Url.toLowerCase()), { url: r2Url, merged_at: mergedAt2 }) + } + + { + const annotations = computeRevertAnnotations([{ url: r1Url, merged_at: mergedAt, targets: [] }]) + assert.equal(annotations.size, 0) + } + + // --- applyRevertAnnotations --- + { + const digest = [ + { url: fUrl, title: "feat F" }, + { url: "https://github.com/Kilo-Org/kilocode/pull/999", title: "untouched" }, + ] + const applied = applyRevertAnnotations(digest, [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + ]) + assert.deepEqual(digest[0].reverted_by, { url: r1Url, merged_at: mergedAt }) + assert.equal(digest[1].reverted_by, undefined) + assert.deepEqual(applied, [[fUrl, r1Url]]) + } + + { + // end-to-end revert-of-revert: F must not gain reverted_by + const digest = [{ url: fUrl, title: "feat F" }] + const applied = applyRevertAnnotations(digest, [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + ]) + assert.equal(digest[0].reverted_by, undefined) + assert.deepEqual(applied, []) + } + + { + // case-insensitive url matching: lowercase signal target vs canonical digest entry + const canonical = "https://github.com/Kilo-Org/kilocode/pull/12249" + const lowerTarget = "https://github.com/kilo-org/kilocode/pull/12249" + const reverter = "https://github.com/Kilo-Org/kilocode/pull/12497" + const digest = [{ url: canonical, title: "feat stream" }] + const applied = applyRevertAnnotations(digest, [ + { + url: reverter, + merged_at: mergedAt, + targets: [{ repo: "kilo-org/kilocode", number: 12249, url: lowerTarget }], + }, + ]) + assert.deepEqual(digest[0].reverted_by, { url: reverter, merged_at: mergedAt }) + assert.deepEqual(applied, [[canonical, reverter]]) + } + + // --- unannotatedRevertSignals --- + { + // partial coverage: F in-digest, G pre-window → only G missed; chains empty + const sUrl = "https://github.com/Kilo-Org/kilocode/pull/500" + const gUrl = "https://github.com/Kilo-Org/kilocode/pull/102" + const signals = [ + { + url: sUrl, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 100, url: fUrl }, + { repo: "Kilo-Org/kilocode", number: 102, url: gUrl }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [[fUrl, sUrl]]) + assert.deepEqual(result, { missed: [{ url: sUrl, targets: [gUrl] }], unparsed: [], chains: [] }) + } + + { + // fully covered signal (no chain) → all three buckets empty + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 100, url: fUrl }, + { repo: "Kilo-Org/kilocode", number: 101, url: f2Url }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [ + [fUrl, r1Url], + [f2Url, r1Url], + ]) + assert.deepEqual(result, { missed: [], unparsed: [], chains: [] }) + } + + { + // depth-2 chain (#4709/#4759): R1→F, R2→R1 — both in chains; F visible as R1 target + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + ] + const result = unannotatedRevertSignals(signals, []) + assert.deepEqual(result, { + missed: [], + unparsed: [], + chains: [ + { url: r1Url, targets: [fUrl] }, + { url: r2Url, targets: [r1Url] }, + ], + }) + } + + { + // depth-3 chain: R1→F, R2→R1, R3→R2 — all three in chains; missed/unparsed empty + const r3Url = "https://github.com/Kilo-Org/kilocode/pull/301" + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + { + url: r3Url, + merged_at: "2026-04-03T00:00:00Z", + targets: [{ repo: "Kilo-Org/kilocode", number: 201, url: r2Url }], + }, + ] + const result = unannotatedRevertSignals(signals, []) + assert.deepEqual(result, { + missed: [], + unparsed: [], + chains: [ + { url: r1Url, targets: [fUrl] }, + { url: r2Url, targets: [r1Url] }, + { url: r3Url, targets: [r2Url] }, + ], + }) + } + + { + // mixed signal: M targets [R1, A, B]; A annotated, B missed; chains lists only R1 + const mUrl = "https://github.com/Kilo-Org/kilocode/pull/800" + const aUrl = "https://github.com/Kilo-Org/kilocode/pull/801" + const bUrl = "https://github.com/Kilo-Org/kilocode/pull/802" + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: mUrl, + merged_at: mergedAt2, + targets: [ + { repo: "Kilo-Org/kilocode", number: 200, url: r1Url }, + { repo: "Kilo-Org/kilocode", number: 801, url: aUrl }, + { repo: "Kilo-Org/kilocode", number: 802, url: bUrl }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [[aUrl, mUrl]]) + assert.deepEqual(result, { + missed: [{ url: mUrl, targets: [bUrl] }], + unparsed: [], + chains: [ + { url: r1Url, targets: [fUrl] }, + { url: mUrl, targets: [r1Url] }, + ], + }) + } + + { + // zero-target signal → unparsed, not missed; chains empty + const emptyUrl = "https://github.com/Kilo-Org/kilocode/pull/400" + const result = unannotatedRevertSignals([{ url: emptyUrl, merged_at: mergedAt, targets: [] }], []) + assert.deepEqual(result, { missed: [], unparsed: [emptyUrl], chains: [] }) + } + + { + // case-insensitivity: annotated set matches target urls differing only by case + const signalUrl = "https://github.com/Kilo-Org/kilocode/pull/600" + const targetMixed = "https://github.com/Kilo-Org/kilocode/pull/700" + const targetLower = "https://github.com/kilo-org/kilocode/pull/700" + const signals = [ + { + url: signalUrl, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 700, url: targetMixed }], + }, + ] + const result = unannotatedRevertSignals(signals, [[targetLower, signalUrl]]) + assert.deepEqual(result, { missed: [], unparsed: [], chains: [] }) + } + + { + // live-window lock: #12497-shaped signal with both targets covered + const s12497 = "https://github.com/Kilo-Org/kilocode/pull/12497" + const t12249 = "https://github.com/Kilo-Org/kilocode/pull/12249" + const t12481 = "https://github.com/Kilo-Org/kilocode/pull/12481" + const signals = [ + { + url: s12497, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 12249, url: t12249 }, + { repo: "Kilo-Org/kilocode", number: 12481, url: t12481 }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [ + [t12249, s12497], + [t12481, s12497], + ]) + assert.deepEqual(result, { missed: [], unparsed: [], chains: [] }) + } + + // --- source-order guards (case-5 style) --- + { + const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8") + assert.ok(collectSrc.includes("./reverts.mjs"), "collect.mjs must import ./reverts.mjs") + const kindIdx = collectSrc.indexOf("revertTitleKind(item.title") + const dropIdx = collectSrc.indexOf("DROP_TITLE.test") + assert.ok(kindIdx >= 0, "revertTitleKind(item.title call missing") + assert.ok(dropIdx >= 0, "DROP_TITLE.test missing") + assert.ok(kindIdx < dropIdx, "revert interception must run before DROP_TITLE") + + const applyIdx = collectSrc.indexOf("applyRevertAnnotations(digest") + const fullIdx = collectSrc.indexOf("digest-full.json") + assert.ok(applyIdx >= 0, "applyRevertAnnotations(digest call missing") + assert.ok(fullIdx >= 0, "digest-full.json write missing") + assert.ok(applyIdx < fullIdx, "annotations must be applied before digests are written") + + assert.ok( + collectSrc.includes('if (revertTitleKind(item.title ?? "")) {'), + 'collect must use exact intercept line if (revertTitleKind(item.title ?? "")) {', + ) + } + + // --- prompt-text guards --- + { + const triagePrompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") + assert.ok(triagePrompt.includes("reverted_by"), "triage-prompt must mention reverted_by") + assert.ok(triagePrompt.includes("stream-liveness"), "triage-prompt must mention stream-liveness") + assert.ok( + triagePrompt.includes("reverted by https://github.com/Kilo-Org/kilocode/pull/12497"), + "triage-prompt must contain exact cite example", + ) + + const editPrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") + assert.ok(editPrompt.includes("reverted_by"), "edit-prompt must mention reverted_by") + assert.ok(editPrompt.includes("current source tree"), "edit-prompt must mention current source tree") + assert.ok(editPrompt.includes("Skipping is a normal outcome"), "edit-prompt must mention skipping outcome") + assert.ok(editPrompt.includes("Existence alone is not enough"), "edit-prompt must mention existence guard") + } +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -934,12 +1831,21 @@ function main() { const cases = [ case1_mergeOrFallback, case2_defectB, + case2b_autoFlag, + case2c_stderrLogAlways, + case2d_redactEnvSecrets, + case2e_prefixSecretOrdering, + case2f_redactStdout, + case2g_redactStream, + case2h_pendingCauseIsReadable, + case2i_budgetsFitWork, case3_watermark, case4_routing, case5_recollection, case6_budgets, case7_cap, case8_triage, + case9_reverts, ] let failed = 0 for (const fn of cases) { diff --git a/.github/docs-sync/triage-prompt.md b/.github/docs-sync/triage-prompt.md index 10e54406e8..dbf2341e70 100644 --- a/.github/docs-sync/triage-prompt.md +++ b/.github/docs-sync/triage-prompt.md @@ -4,11 +4,12 @@ The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud a A PR is docs-worthy ONLY if a user of Kilo Code would need to learn something new or change how they use the product after this PR ships. Examples: new commands, flags, settings, UI workflows, providers, pricing/limits changes, breaking behavior changes, or fixes that change documented behavior. -A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, or a change only visible to contributors or self-hosters. +A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, a change only visible to contributors or self-hosters, an internal technical fix or plumbing change with no user-visible behavior or workflow change (for example stream-liveness/watchdog plumbing, internal retry or timeout handling with no user-facing setting), or UI or rendering polish that does not change what a user must do or learn. Rules: - Include every input PR exactly once, identified by its `number` and `url`. Never invent PRs. +- A digest entry with a `reverted_by` field had its changes reverted by that later PR in the same window. Treat such PRs as not docs-worthy unless the entry's body or file list shows clear evidence the change was re-landed after the revert. When `reverted_by` drives your verdict, the `reason` must cite it, e.g. "reverted by https://github.com/Kilo-Org/kilocode/pull/12497". - When unsure, set `docs_worthy` to false and explain the doubt in `reason`. - `target_sections` is only filled for docs-worthy PRs. Use rough docs areas, e.g. `getting-started`, `code-with-ai/platforms/cli`, `code-with-ai/platforms/vscode`, `code-with-ai/agents`, `ai-providers`, `teams`, `enterprise`, `automate`. - `reason` is one short sentence, written for the human who reviews the final docs PR. diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index eb075980b5..a01eef0a99 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -71,8 +71,13 @@ function triageChunk(chunk, index, budgetDeadline) { break } + // Headless `kilo run` auto-rejects every permission ask; without --auto the + // agent cannot run shell commands. SECURITY: --auto grants unrestricted bash + // to an agent steered by external PR content. Hardening deferred: a scoped + // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the + // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), streamStdout: false, label: `triage chunk ${index} attempt ${attempt}`, diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 5a32e87698..147cde8a47 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -66,8 +66,13 @@ jobs: sync: if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 - # Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve. - timeout-minutes: 120 + # Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve. + # These are ceilings, not costs: a caught-up run triages ~2 chunks and edits + # ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding + # constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and + # 31 unedited purely on budget, with no attempt made. See the throughput note + # in the PR description for the arithmetic. + timeout-minutes: 240 env: # Both are required: without KILO_ORG_ID the gateway bills the key # owner's personal balance (402 "Add credits") instead of the org. @@ -116,6 +121,9 @@ jobs: if: steps.collect.outputs.count != '0' env: SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} + # Default 35 fit only 8 of 11 chunks on a 254-PR window. Headroom for + # --auto making chunks slower now that the agent really runs commands. + TRIAGE_BUDGET_MINUTES: "90" run: node .github/docs-sync/triage.mjs - name: Filter docs-worthy PRs @@ -153,6 +161,11 @@ jobs: - name: Update docs (Kilo CLI, batched) if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true continue-on-error: true + env: + # Default 50 fit only 4 of 11 batches. A healthy --auto batch is ~8 min, + # and edit.mjs will not start a batch without EDIT_BATCH_TIMEOUT_MINUTES + # (15) left, so 120 covers 14 batches = 70 PRs against ~5 worthy/day. + EDIT_BUDGET_MINUTES: "120" run: node .github/docs-sync/edit.mjs - name: Verify docs build and tests @@ -174,9 +187,19 @@ jobs: NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} run: | set -o pipefail - kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \ + # Headless kilo run auto-rejects every permission ask; the runner has no + # user config granting bash, so without --auto the agent cannot run ordinary + # shell commands against the repository. + kilo run --auto "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \ -m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \ - | tee -a docs-sync-out/edit-log.txt + | node .github/docs-sync/redact-stream.mjs \ + | tee -a docs-sync-out/edit-log.txt \ + || echo "::warning::kilo fix pass exited nonzero; re-verifying anyway" + # The rebuild below decides this step's outcome, not the agent's exit code. + # Without the guard above, `set -o pipefail` + the default `bash -e` would + # abort here once the CLI half of this PR ships: a mid-stream session error + # (or an auto-rejected ask) exits 1, verify2.log is never written, and + # `Re-verify status` reports VERIFIED=false even when the docs build fine. { bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log - name: Re-verify status diff --git a/.kilo/plans/agent-manager-multi-project-configuration.md b/.kilo/plans/agent-manager-multi-project-configuration.md new file mode 100644 index 0000000000..340082fbb6 --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-configuration.md @@ -0,0 +1,262 @@ +# Agent Manager multi-project configuration architecture + +**Status:** Blocking architecture for multi-project release + +**Date:** 2026-07-22 + +This document is the canonical configuration specification for Agent Manager multi-project support. The main UI/runtime plan references it and must not duplicate or contradict it. + +The executable sequence from the current branch is [`agent-manager-multi-project-implementation-handoff.md`](./agent-manager-multi-project-implementation-handoff.md). + +## Decision + +Keep the useful current split between user and project settings, but make every Settings read and write target explicit, immutable, revisioned, and independent from Agent Manager activation. + +Multi-project must not ship broadly while Settings can load a draft for project A and resolve its save target from the mutable active project B. + +## Current behavior + +Kilo has four configuration stores: + +| Store | Example | Owner | +|---|---|---| +| VS Code preferences | VS Code `settings.json` | This VS Code user/installation | +| Kilo user config | `~/.config/kilo/kilo.json` | User defaults across projects and Kilo clients | +| Kilo project config | `/.kilo/kilo.jsonc` | Repository behavior and overrides | +| Runtime/session state | In-memory, directory-qualified | One project, worktree, or session | + +The shared Settings save currently calls `splitConfigByScope()`: + +- `commit_message` is written to project config; +- `indexing.enabled` is written to project config; +- all other generic Settings fields are written to user config. + +The Indexing tab also has an explicit Global/Project selector and can write the entire `indexing` object to either layer. This is too broad because project config can receive provider/model/vector-store credentials and infrastructure settings. + +Several controls are VS Code preferences and bypass Kilo config entirely, including autocomplete UI, browser automation, notifications, max auto-approve cost, commit-message output language, and indexing button visibility. + +## Confirmed blocking failure + +The current protocol does not bind a draft to the config target it was loaded from: + +1. `KiloProvider.fetchAndSendConfig()` resolves a mutable current directory and sends unqualified `configLoaded` state. +2. The webview owns one global/project/effective draft. +3. Agent Manager changes the active project from A to B. +4. The webview sends an unqualified `updateConfig`. +5. `KiloProvider.handleUpdateConfig()` resolves the current directory again at save time and may write A's draft to B. + +Reads are directory-scoped, but writes are not bound to the read target. This is the release blocker. + +The backend also lacks an expected target/revision precondition, so external editors or another window can overwrite a config between read and save. + +## Ownership policy + +### VS Code preferences + +These remain in VS Code settings and do not participate in project config: + +- extension language and presentation preferences; +- autocomplete enablement, keybindings, provider, and model; +- browser automation enablement/system Chrome/headless mode; +- notification enablement and sound; +- maximum automatic approval cost; +- commit-message output language; +- indexing button visibility while indexing is disabled; +- multi-project feature enablement. + +### Kilo user config + +These are personal defaults or security policy and should be edited in User scope: + +- default, small, and subagent models and variants; +- provider enablement, custom providers, credentials, and endpoints; +- user/global agents and default agent; +- permission defaults and user tool defaults; +- sandbox policy, network access, writable paths, and allowed hosts; +- compaction, checkpoint/snapshot, and tool-output defaults; +- username and display behavior; +- sharing, remote control, telemetry, and experimental features; +- user/global formatter, LSP, MCP, skills, instructions, commands, and workflows; +- indexing provider, model, credentials, vector storage, and global tuning defaults. + +A trusted project may override many of these at runtime, but editing User scope never writes those overrides. + +### Kilo project config + +These describe repository behavior and are valid project settings: + +- commit-message prompt; +- repository indexing file extensions, include/ignore rules, and deliberate project tuning overrides; +- repository instructions; +- repository skill paths; +- repository commands/workflows; +- trusted project agents; +- trusted project MCP servers; +- repository formatter/LSP overrides; +- repository watcher ignores; +- repository-specific tool restrictions and permission requests. + +Project configuration may override user model/agent/tool defaults, but provider credentials and security-policy weakening must not be silently authored into a repository. + +### Machine-local project consent + +Indexing enablement is privacy consent, not repository configuration. Store it outside the repository, keyed by canonical `ProjectId` in machine-local extension state: + +- newly observed projects default to indexing disabled; +- users explicitly enable indexing for one project on this machine; +- repository config cannot enable indexing; +- repository config may describe what to index, but consent gates whether indexing starts; +- canonical project identity prevents a symlink or alternate path from bypassing consent. + +Effective indexing requires both valid user-global indexing configuration and machine-local consent for that project. + +### Current tabs + +| Settings tab | Correct editable target | +|---|---| +| Models | User by default; explicit Project scope may override models/agents | +| Providers | User only for credentials/endpoints; project-provided entries are source-labelled | +| Agent Behaviour | User or explicit trusted Project scope | +| Auto Approve | User Kilo config; max cost remains VS Code preference | +| Browser | VS Code preferences | +| Checkpoints | User default or explicit Project override | +| Display | User config | +| Autocomplete | VS Code preferences | +| Notifications | VS Code preferences | +| Context | User defaults; repository watcher/instruction rules in explicit Project scope | +| Commit Message | Project scope for prompt; language remains VS Code preference | +| Indexing | User for provider/model/credentials/storage; machine-local project consent for enablement; Project for repository rules | +| Experimental | User config; multi-project also mirrors to VS Code preference | +| Sandboxing | User config only | +| Language | VS Code preference | +| MCP/Commands/Skills | User defaults or explicit trusted Project scope | + +No field silently chooses a file during save. The UI must display its scope. + +## Settings UX + +Use explicit scope and project controls: + +```text +Scope: User | Project + +Project: backend +Target: /projects/backend/.kilo/kilo.jsonc +``` + +- User scope always targets user config. +- Project scope requires an explicit trusted project selector. +- The Settings selector is separate from Agent Manager's active project. +- Opening Settings may initialize the project selector once from the current project, but later Agent Manager switches never change it. +- A dirty project draft cannot move to another project. Selector changes require Save, Discard, or Stay. +- Inherited values show source badges such as User, Project: backend, and Managed. +- Project scope offers Override and Reset to inherited. +- Project-sourced providers cannot be silently deleted from project config through User scope. + +Runtime config still follows the exact session directory. Settings Project scope targets the registered project root, not the active worktree. Worktree-config editing is a separate future feature requiring an explicit `WorktreeRef`. + +## Immutable binding contract + +A Settings read returns an opaque binding: + +```ts +interface SettingsBinding { + id: string + connectionGeneration: number + scope: "global" | "project" + project?: { + projectId: string + root: string + generation: number + } + directory: string + target: { + scope: "global" | "project" + path: string + revision: string + exists: boolean + writable: boolean + } +} +``` + +The write contains only the opaque binding and patch: + +```ts +interface WriteSettingsConfig { + type: "settingsConfig.write" + requestId: string + bindingId: string + set: Record + unset: string[][] +} +``` + +The extension stores the authoritative binding. On write it must: + +1. reject unknown/expired bindings; +2. verify project existence, generation, and trust; +3. capture the binding before the first await; +4. use the binding's stored directory and scope; +5. never call `getWorkspaceDirectory()`, `contexts.active()`, or use a worktree/session fallback; +6. clear a draft only from the matching `{ requestId, bindingId }` response. + +Bindings expire after save, reconnect, trust revocation, project removal, or context generation change. + +## Backend revision contract + +`GET /config/overlay` must return the exact global/project target path, parsed raw target config, effective config/source metadata, and a revision. + +The revision is a SHA-256 fingerprint of canonical target path, existence marker, and exact file bytes. This catches content changes, JSONC comment-only edits, and target changes. + +`PATCH /config/overlay` accepts one scope and requires: + +```ts +{ + scope: "global" | "project" + set: Record + unset: string[][] + expected: { + path: string + revision: string + } +} +``` + +The backend must re-resolve the authoritative target, verify path/revision under a target lock, patch the raw target layer, validate it, atomically replace the file, and return a fresh snapshot. It never accepts an arbitrary client path. + +Expected failures include expired binding, unknown/untrusted project, changed target, revision conflict, invalid config, non-writable target, and I/O failure. Every failure preserves the draft. + +## Required implementation + +1. Add revisioned target descriptors and compare-and-swap writes to the Kilo config overlay API. +2. Split activation-bound runtime config state from binding-keyed Settings editor state. +3. Replace unqualified `configLoaded`/`updateConfig` with settings read/write messages carrying request and binding IDs. +4. Replace hidden `splitConfigByScope` saves with explicit scope on every editable control. +5. Restrict Indexing Project scope to repository rules; keep provider/model/credentials/storage in User scope. +6. Move indexing enablement from project config to machine-local consent keyed by canonical `ProjectId`, default off. +7. Audit direct config mutators outside the save bar, especially provider disconnect, imports/resets, custom providers, work styles, permission rules, and indexing actions. +8. Make Open Project Config take a `ProjectRef`, resolve the immutable registered root, and verify trust. +9. Partition config caches/events by scope, directory, target, revision, and activation generation. + +## Blocking tests + +- Load Settings for A, switch Agent Manager to B, save: only A's bound target changes. +- Same test while selecting A/B worktrees and sessions. +- User-scope save always changes only user config. +- Project-scope save requires the explicit trusted project and changes only its registered root config. +- Dirty drafts survive Agent Manager switches and cannot migrate between Settings projects. +- Out-of-order reads and writes update only the matching request/binding. +- External file change causes a revision conflict without losing the draft. +- A changed config target path causes a target conflict. +- Project removal, generation change, or trust revocation expires its binding. +- Indexing provider/model/credentials/storage never enter project config through the form. +- A repository file containing `indexing.enabled: true` cannot grant indexing consent. +- New projects default to indexing disabled until explicitly enabled on this machine. +- Consent follows canonical project identity across symlink/path aliases and never leaks to another project. +- `commit_message.prompt` and repository indexing rules still support explicit project writes. +- Runtime worktree config uses the worktree directory while Project Settings remains bound to the registered project root. + +## Release gate + +Keep multi-project disabled by default until the immutable binding/revision contract and the blocking tests above are implemented. The useful existing project-local behavior should be preserved, not removed; its write target must become explicit and immutable. diff --git a/.kilo/plans/agent-manager-multi-project-implementation-handoff.md b/.kilo/plans/agent-manager-multi-project-implementation-handoff.md new file mode 100644 index 0000000000..510a5e1c38 --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-implementation-handoff.md @@ -0,0 +1,459 @@ +# 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. diff --git a/.kilo/plans/agent-manager-multi-project-runtime.md b/.kilo/plans/agent-manager-multi-project-runtime.md new file mode 100644 index 0000000000..25bb0d9e07 --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-runtime.md @@ -0,0 +1,351 @@ +# Agent Manager multi-project runtime + +> UI architecture update: the active-body plus background-summary model in this document is superseded by `agent-manager-multi-project-uniform-ui.md`. The runtime findings remain useful, but the final UI renders one permanent real interactive body for every expanded project and requires strict project-qualified routing before background actions are enabled. + +**Status:** Runtime foundation implemented (this worktree); accordion UI pending + +## Implementation status (2026-07-20) + +Landed in this worktree, all covered by unit tests (`tests/unit/agent-project-*.test.ts`): + +- `src/agent-manager/project-paths.ts` — canonical root resolution (`realpath` + `normalizePath`), case-aware `samePath`, deterministic `projectIdFor(root)` (sha1, stable across restarts), `resolveGitRoot`. +- `src/agent-manager/project-registry.ts` — versioned global catalog of **additional** projects (storage-injected, corrupt-safe, dedupe by id, trust/label/order). The pinned workspace project is never persisted. +- `src/agent-manager/project-context.ts` — immutable `ProjectContext` (owns WorktreeStateManager/WorktreeManager/SetupScriptService/stale set per canonical root, lazy creation, peek accessors) and `ProjectContexts` coordinator (pinned derivation, active/expanded lifecycle, trust+flag gating, `syncPinned()` for workspace changes, webview `ProjectSnapshot`s). +- `src/agent-manager/project-messages.ts` — vscode-free handlers: `requestProjects`, `addProject` (folder picker → git root resolve → dedupe), `removeProject`, `selectProject`, `setProjectExpanded`, `trustProject`. All fail closed when the flag is off. +- `src/agent-manager/project-wiring.ts` — factory assembling registry/contexts/deps/host listeners (extracted to keep `AgentManagerProvider.ts` under its 2000-line cap). +- `AgentManagerProvider` — fields `state/worktrees/setupScript/staleWorktreeIds` replaced by context-backed getters; every existing handler now operates on the active project with zero per-handler changes. `onMessage` consumes project messages first and drops messages whose `projectId` mismatches the active project (stale-pane protection). `activateProject` re-runs `initializeState` for the new context; pollers/importer/run/terminal follow the active context through the existing getters. `agentManager.state` now carries `projectId`. +- Protocol — `agentManager.projects` out-message (`multiProject` flag + `ProjectSnapshot[]`); new in-messages listed above; `projectId?` on `agentManager.state` and `createWorktree`. Webview type mirrors updated (`webview-ui/src/types/messages/{extension,webview}-messages.ts`). +- Host — `pickFolder`, `multiProject`, `readProjects`/`writeProjects` (globalState key `agentManager.projects`), `onDidChangeWorkspaceFolders`, `onDidChangeMultiProject`. +- Setting — `kilo-code.new.agentManager.multiProject` (boolean, default false, application scope). +- Changeset — `.changeset/agent-manager-multi-project.md`. + +Behavior with flag off: only the pinned workspace project exists; snapshots contain exactly one project; all registry data is preserved but hidden; no UI change. + +## Remaining for the UI session (accordion) + +- Project accordion rendering from `agentManager.projects`: header-only rows for collapsed/uninitialized projects (registry metadata only), full existing Local/worktree body for the active project, expanded non-active bodies from per-context state (extension must push per-context state with that context's `projectId` — currently `pushState` only pushes the active context). +- Add-project entry point, trust CTA (sends `agentManager.trustProject` then expand/select), remove/rename in project menu. +- `selectProject` before any repo action in a non-active project; send `projectId` on `createWorktree` (extension drops mismatches). +- New Worktree dialog project selector (defaults to active project). +- Extension follow-ups: hidden-cadence polling for expanded projects; session-owner index so Local sessions of project B open/share correctly even while A is active (currently Local sessions resolve through the active root — acceptable only while the UI activates before opening). +- Implemented: per-context `pushState` for expanded non-active projects; per-project git stats/PR pollers (`ProjectPollers` in `project-pollers.ts`) so every expanded accordion receives live stats/PR data tagged with its projectId; webview `project-live.ts` store routing per-project payloads into per-project summary stores; `activate()` keeps the previously active project expanded. +- Known boundary: `KiloProvider.projectID` and session refresh remain single-project; cross-project SSE filtering by session→directory owner is a follow-up (see "Verified findings" below). + +## Objective (original) + +Build the project-aware Agent Manager runtime before changing the UI. The eventual UI is a pinned current-project view plus lazily initialized additional project accordions, with one shared tab/detail area. The first backend slice must be mergeable with the flag disabled and must route today's single project through the same project-aware contracts that secondary projects will use. + +The implementation must preserve these invariants: + +- The first project is always the canonical Git root of the first VS Code workspace folder in the current window. It is derived at runtime, is always first, and cannot be removed or replaced by persisted state. +- Global extension storage contains only the catalog of additional projects and project-list UI metadata. Each repository remains authoritative for its own `.kilo/agent-manager.json`. +- A collapsed, never-opened additional project causes no `.kilo/agent-manager.json` read, Git mutation, setup/run initialization, terminal creation, or poller subscription. +- Every session/worktree/draft operation has a project identity internally. Once secondary-project activation is enabled and more than one project is exposed, missing or mismatched identity is rejected rather than routed to the current workspace. +- The feature flag gates catalog exposure, secondary activation, and multi-project wire/UI behavior. It does not select a separate legacy implementation. + +## Verified findings + +- `AgentManagerProvider` currently owns the panel and all repository-bound resources in one 1,941-line singleton. Its root is always `Host.workspacePath()` and its state/managers are lazily cached against that root (`packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts:56-212`, `1654-1695`). The architecture test caps this file at 2,000 lines and explicitly forbids raising the cap (`packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts:793-854`). +- Opening the panel immediately initializes the current repository, performs `ensureGitExclude`, loads `.kilo/agent-manager.json`, discovers/restores worktrees, registers sessions, starts state-dependent behavior, and recovers prompts (`AgentManagerProvider.ts:267-360`). This whole sequence cannot run for a collapsed catalog entry. +- `WorktreeStateManager` is correctly repository-scoped by constructor root and writes only `/.kilo/agent-manager.json` (`WorktreeStateManager.ts:100-124`, `632-656`, `797-847`). Its save queue and `flush()` are suitable for a context shutdown barrier, but it has no cancellation/dispose primitive (`755-795`). +- The embedded Agent Manager `KiloProvider` is one provider attached to the one panel/webview (`agent-manager/vscode-host.ts:67-169`). It already routes many SDK calls through a session-to-directory map, but local sessions are allowed to fall back to `workspaceFolders[0]` (`KiloProvider.ts:4343-4378`; `kilo-provider-utils.ts:319-345`). That fallback becomes a cross-project write risk. +- `KiloProvider` has singleton project state (`projectID`, `currentSession`, project-scoped caches, one active project directory) and current session refresh treats all extra directories as worktrees of one canonical project (`KiloProvider.ts:344-359`, `1984-2029`; `kilo-provider-utils.ts:230-295`). It is not multi-project-correct without refactoring. +- The shared backend and generated SDK support an explicit `directory` on session share and unshare (`packages/sdk/js/src/v2/gen/sdk.gen.ts:4733-4795`). Sharing must therefore use the same project/session route as prompts and transcript reads. +- Backend session IDs are high-entropy process-global IDs (`packages/opencode/src/id/id.ts:1-17`, `23-33`, `42-49`). Raw IDs can remain the SDK/server identifier, but the UI/runtime identity still needs a project-qualified reference, and route registration must reject an observed cross-project collision. +- `session.status` currently discards the SSE directory before invoking branch naming (`AgentManagerProvider.ts:204-220`). The orchestration bridge subscribes globally and rejects requests outside its root (`orchestration-bridge.ts:175-203`). Creating one unchanged bridge per project would let the wrong bridge reject a valid request before the correct project handles it. +- Multiple `SessionTerminalManager` instances would each register the same global VS Code commands and terminal listeners (`SessionTerminalManager.ts:35-81`). It must remain panel/coordinator-owned, with project-qualified keys and explicit CWDs. Also, `TerminalRouter` currently falls back from an unknown worktree ID to the repository root (`terminal-routing.ts:120-130`), which must be removed in strict routing. +- `VscodeHost.workspacePath()` uses `workspaceFolders[0]` (`vscode-host.ts:172-174`; `review-utils.ts:12-15`). It loses URI scheme/authority, and `openFolder` recreates paths with `Uri.file`. A catalog containing only path strings would be unsafe across local, SSH, container, and other remote extension hosts. +- Existing Kilo experimental config is CLI/project config, not an application feature-flag service (`features.ts`; `webview-ui/src/types/messages/config.ts`). A multi-project bootstrap flag cannot be project-scoped because choosing which project config to read is the behavior being gated. + +## Architecture decisions + +### 1. Project identity and registry semantics + +Add a VS Code-free `ProjectRegistry` backed by an injected storage and resolver interface. `vscode-host.ts` implements those interfaces using `ExtensionContext.globalState`, workspace/open-dialog URIs, filesystem canonicalization, and Git. + +Use these core types in `src/agent-manager/project.ts`: + +```ts +type ProjectId = string + +interface ProjectLocation { + uri: string + scheme: string + authority: string + root: string + commonGitDir: string +} + +interface ProjectDescriptor extends ProjectLocation { + id: ProjectId + name: string + pinned: boolean + collapsed: boolean + order: number + status: "unverified" | "ready" | "missing" | "notGit" | "wrongAuthority" +} +``` + +`root` is the canonical filesystem Git toplevel. Resolve it only when deriving the pinned project, adding a project, or expanding an unverified project: + +1. Start from the selected workspace/folder URI. +2. Reject a URI whose scheme/authority is not the current extension-host scope. +3. Run `git -C rev-parse --show-toplevel` through the existing hidden-window process wrapper. +4. Resolve the result with `fs.promises.realpath`; normalize separators, trailing separators, and Windows case for identity comparison. +5. Resolve and canonicalize `git rev-parse --path-format=absolute --git-common-dir` as `commonGitDir`. +6. Derive `ProjectId` deterministically as a versioned SHA-256/base64url hash of `scheme + NUL + authority + NUL + normalized canonical root`. Do not expose a path-concatenated ID on the wire. + +Different linked worktree roots have different project IDs, but the first iteration must reject adding a project whose canonical `commonGitDir` matches any project already exposed in the window. Two Agent Manager contexts mutating the same Git common directory would bypass the current root-keyed worktree lock and could manage the same branches twice. This is a product-valid restriction to distinct Git projects, not a path workaround. Also change `WorktreeManager`'s write-lock key to canonical `commonGitDir` so future relaxation does not reintroduce `index.lock` races. + +Persist one unsynced global-state value under `kilo.agentManager.projects.v1`: + +```ts +interface StoredProjectCatalogV1 { + version: 1 + projects: Array<{ + id: ProjectId + uri: string + scheme: string + authority: string + root: string + commonGitDir: string + name?: string + collapsed: boolean + order: number + addedAt: string + }> +} +``` + +Do not call `setKeysForSync`; machine/remote paths must not sync to other machines. Serialize mutations in-process. Each mutation re-reads the latest value, validates it, applies add/remove/reorder/metadata changes, and performs one `globalState.update`. + +Registry behavior is exact: + +- `snapshot(flag)` derives the pinned current project fresh. It never reads a persisted active project. +- The pinned descriptor is first, has `pinned: true`, and defaults to expanded. It is not written into the catalog. +- Persisted entries matching the pinned project ID are suppressed, not deleted. They reappear as additional projects when another window has a different pinned project. +- Additional entries are filtered to the current URI scheme/authority before exposure. Entries for other authorities remain untouched in storage. +- Reading the catalog does not stat, realpath, run Git, or open per-repository state. Persisted additional entries start as `unverified`. +- Adding validates/canonicalizes the chosen folder, rejects the pinned project, duplicate IDs, duplicate common Git directories, and authority mismatches, then persists it. A newly added project may be marked expanded and explicitly activated by the caller; listing alone never initializes it. +- A missing or no-longer-Git root is marked unavailable only when expansion validates it. Keep the entry and its metadata. Do not prune it automatically and do not create a `ProjectContext`. +- Removing is allowed only for non-pinned projects. It removes only the catalog entry after the context is idle/disposed; it never deletes `.kilo`, worktrees, sessions, branches, or shares. +- On every extension/panel restart, the active selection is the pinned project's Local context. Persisted additional projects and collapse/order/name metadata survive, but a stale secondary active session does not override the current VS Code window. + +For a multi-root VS Code workspace, preserve current behavior by deriving the pinned project from `workspaceFolders[0]`, then canonicalize that folder to its Git toplevel. Do not silently select another workspace folder if the first is not a Git repository. + +### 2. Coordinator and immutable lazy ProjectContext + +Turn `AgentManagerProvider` into the panel-level coordinator and extract repository behavior into `project-context.ts`. Do not instantiate one current `AgentManagerProvider` per project. + +The coordinator owns: + +- the one panel and embedded `KiloProvider`/`SessionProvider`; +- `ProjectRegistry`, active `ProjectId`, exposed descriptors, and `Map`; +- a shared `Semaphore(3)` for Git/network polling across all projects; +- the one project/session router; +- one project-aware `AgentManagerVisiblePresence`; +- one `SessionTerminalManager` and one project-aware `TerminalRouter`; +- one global orchestration ingress and the global SSE/tool/status subscriptions; +- project and activation generations used to discard stale async results. + +Each `ProjectContext` is constructed once with an immutable descriptor/root/common Git directory and owns only repository-bound resources: + +- `WorktreeStateManager`, `WorktreeManager`, `SetupScriptService`, and repository `GitOps`; +- local diff functions, `WorktreeDiffController`, `WorktreeImporter`, and `RunController`; +- `GitStatsPoller`, `PRStatusBridge`/poller, and `BranchNamingController`; +- per-project cached state/stats/stale IDs, managed/open session refs, and initialization promise; +- project-specific message handlers currently embedded in `AgentManagerProvider`. + +It does not own a webview, `KiloProvider`, VS Code terminal command registrations, global connection subscriptions, or a global orchestration subscription. + +Lifecycle: + +1. `cold`: descriptor only. No state manager, manager, poller, watcher, or per-repo read exists. +2. `initializing`: one shared promise validates the location, constructs resources, performs the existing `ensureGitExclude -> state.load -> recoverWorktrees -> register routes` sequence, and captures a context generation. +3. `ready/expanded`: posts project-stamped state and permits project actions. Pollers run only when both the panel is visible and this project is expanded. Multiple expanded contexts may be ready concurrently. +4. `suspended/collapsed`: if active, the coordinator first activates pinned Local (or another explicit target). Stop stats/PR/diff polling and watchers, stop accepting new project mutations, wait for the current mutation barrier, flush state, and detach project-specific event side effects such as automatic branch naming. Keep validated session routes/tracking so a running backend session can still surface status and permission/question events without a repository read. Do not abort agents merely because an accordion collapsed. +5. `disposed`: increment generation first, stop/dispose resources, await terminal-router/run/mutation cleanup and state flush, detach routes, then remove the exact context object from the map. Late completions may neither post nor mutate a replacement context. + +Expansion of a suspended context resumes its existing in-memory state and pollers; expansion of a cold context initializes. Concurrent expansions share the same promise. Collapse during initialization records the desired collapsed state, allows non-cancellable Git/state work to finish, suppresses stale posts, then immediately suspends. + +The coordinator routes global events by canonical event directory before invoking a context. `session.status` must retain its directory. A single orchestration ingress determines the one owning context and delegates there; it rejects an unknown directory once, globally. It must never create one rejecting bridge per project. A collapsed project does not auto-initialize in response to an unsolicited orchestration event; return a typed `project_inactive` result. + +Panel close keeps existing semantics for sessions actually opened/created by that panel, but aggregates their project-qualified routes before aborting. Project collapse and flag-off transitions do not abort them. Removing a project is refused while one of its tracked sessions or Agent Manager run/mutation is busy, rather than orphaning an interaction prompt. + +### 3. One embedded KiloProvider, made project-aware + +Use one embedded `KiloProvider`. Multiple providers are not safe or useful here: + +- there is one webview and one message handler; +- each provider would own conflicting current-session/cached UI state and duplicate SSE handling; +- multiple terminal/visibility registrations would be ambiguous; +- it would not solve session identity at the shared webview boundary. + +Refactor its Agent Manager adapter around an injected project/session route service while leaving sidebar and standalone providers unchanged. The route service supplies: + +```ts +interface ProjectSessionRef { + projectId: ProjectId + sessionId: string +} + +interface SessionRoute extends ProjectSessionRef { + directory: string + generation: number +} +``` + +Internally key UI maps by `projectId + NUL + sessionId`; never use that concatenation on the wire. Keep the raw `sessionId` for SDK calls and backend presence. Maintain a reverse raw-ID index only to process backend events. If the same raw ID is observed in two projects, mark it ambiguous and reject/drop operations and events that cannot also be disambiguated by event directory. + +Required routing changes: + +- Register an explicit route for every Agent Manager session, including Local sessions at a project root. Do not omit a map entry merely because it equals the first workspace root. +- New/draft operations resolve from the outer project envelope and an explicit Local/worktree context. Unknown worktree IDs are errors; remove `TerminalRouter`'s fallback to root. +- Existing session operations resolve only from `ProjectSessionRef`. In strict multi-project mode, an unknown session never falls back to `workspaceFolders[0]` or the currently active project. +- Child/subagent sessions inherit the parent's project and directory. A directory-bearing event must also pass the owning-context check before registration. +- Session list refresh runs per project root plus that project's worktree directories. It emits sessions stamped with that project; it does not infer one canonical `projectID` from the first root and merge all projects into it. +- Replace singleton Agent Manager use of `KiloProvider.projectID` with the route table/directory ownership check. Keep existing project filtering for non-Agent-Manager provider instances. +- On active-project/session change, call one `activateProject(projectId, root)` path. It updates `projectDirectory`, invalidates/refetches project-scoped config, providers, agents, commands, skills, MCP, indexing, sandbox, and Git status for that root. Guard every async refresh with activation generation so project A cannot populate caches after switching to B. +- Every session create, continuation, prompt, permission/question response, terminal operation, diff operation, file/context operation, and share/unshare SDK call receives the resolved explicit directory. + +This picks up each secondary project's config naturally through the CLI's directory-scoped instance/config resolution. Do not copy provider/config/settings data into global storage. + +### 4. Project-qualified protocol and share semantics + +Define an internal/wire envelope in `agent-manager/types.ts` and mirror it in `webview-ui/src/types/messages/agent-manager.ts`: + +```ts +interface ProjectStamp { + projectId: ProjectId + generation: number +} + +interface ProjectEnvelope { + type: "agentManager.projectMessage" + project: ProjectStamp + payload: T +} +``` + +Use project-qualified references for sessions, worktrees, and drafts: + +```ts +interface SessionRef { projectId: ProjectId; sessionId: string } +interface WorktreeRef { projectId: ProjectId; worktreeId: string } +interface DraftRef { projectId: ProjectId; draftId: string } +``` + +Raw IDs remain in each repository's existing state file. The repository/context supplies the project identity when state is loaded. Do not migrate session IDs into global state and do not permit moving an existing session record between projects. A future cross-project continuation must explicitly fork/create in the target project; relabeling a raw session is invalid. + +Protocol rules: + +- The coordinator is the only component that unwraps inbound envelopes and wraps context output. +- Global catalog/route errors are global messages. Repository state, stats, session metadata, diff, run, PR, terminal, and chat messages are project envelopes. +- Preserve `requestId` inside the payload. Return a typed route error (`project_required`, `project_unknown`, `project_inactive`, `project_mismatch`, `session_unknown`, or `session_ambiguous`) without forwarding the original message. +- Validate the envelope project against the context, persisted session/worktree ownership, session route, and event/request directory. A valid raw ID in the wrong envelope is still rejected. +- Outbound generation lets the webview/coordinator discard messages from a collapsed/disposed/replaced context. +- While the flag is off, or while only the pinned project is exposed, a compatibility adapter wraps today's unqualified webview messages with the pinned project and unwraps pinned output to the existing shape. The core handlers still receive envelopes. +- Once the flag is on and a second project is exposed, unqualified project/session/worktree messages fail closed. The multi-project UI must send/consume envelopes and use composite refs as tab/store keys. + +Sharing is an operation on `SessionRef`, not on a bare session ID: + +- Manual share calls `client.session.share({ sessionID, directory })`; unshare uses the exact same resolved route. +- Automatic sharing remains a project config behavior because session creation is sent with the target project's directory. +- Store no share URL or share state in the project catalog. Session list/get/update data remains authoritative and is enriched with `projectId` on output. +- Removing/collapsing a project never unshares its sessions. A share URL is externally usable, but reopening or mutating the local session still requires its local project route. +- A mismatched or missing project cannot share/unshare, even if the raw session ID happens to resolve elsewhere. + +### 5. Feature flag, restart, and migration behavior + +Contribute an application-scoped VS Code setting, default false, for example `kilo-code.new.agentManager.multiProject`, with `scope: "application"`. Read it in the host/coordinator; do not put it in CLI `experimental` config. + +- Flag off: derive and initialize only pinned current project, use the compatibility wire adapter, and preserve the exact current view. Read but do not expose, validate, initialize, rewrite, or delete additional catalog entries. +- Flag on: publish lightweight descriptors. Only explicit expansion initializes an additional project. Selecting Local/worktree also expands and initializes its owner before activation. +- Runtime on -> off: increment activation generation, activate pinned Local, suspend all secondary contexts, switch to compatibility protocol, and preserve catalog/per-repo state. Do not abort backend sessions. +- Runtime off -> on: expose stored descriptors as cold/unverified. Do not eagerly restore a persisted secondary active project. + +No `.kilo/agent-manager.json` schema migration is needed. Existing state is loaded unchanged by the pinned context, and additional contexts load their own existing files only when expanded. An absent catalog means no additional projects. Invalid catalog entries are ignored in the snapshot and logged, but a successful write must preserve valid entries from other authorities. Never scan the filesystem to discover projects as migration. + +On restored legacy panel/webview state, unqualified tabs are accepted only in compatibility single-project mode. When multi-project mode exposes a second project, send a protocol reset and rebuild tabs/session lists from project envelopes instead of guessing which project owns a restored raw session ID. + +Remote behavior: + +- Scope catalog exposure and identity by full URI scheme + authority, not `vscode.env.remoteName` alone. +- Run Git and filesystem operations in the current extension host against `fsPath`; preserve the original URI for `openFolder` and picker operations. +- Reject selection from another authority. Keep such stored entries for their matching window/host. +- Unsupported virtual filesystems, missing roots, permission failures, and non-Git directories become unavailable descriptors after explicit validation. They do not fall back to the pinned root. + +## Ordered implementation plan + +### Slice A: mergeable routing foundation before multi-project UI + +This is the narrow first PR. It produces no secondary-project UI, but the current pinned project runs through the durable identity/routing contracts and the registry is fully tested. + +1. Add `src/agent-manager/project.ts` with IDs, locations, descriptors, stamps, envelopes, and ref types. Add `src/agent-manager/project-registry.ts` with injected storage/resolver, v1 schema validation, pinned merge, authority filtering, dedupe, and serialized mutations. +2. Extend `Host` in `src/agent-manager/host.ts`; implement global-state storage, canonical Git/URI resolution, pinned project resolution, and the application flag in `src/agent-manager/vscode-host.ts`. Preserve URIs in `openFolder`. Add the setting contribution in `packages/kilo-vscode/package.json`. +3. Add `src/agent-manager/project-session-router.ts`. It must register Local and worktree routes, resolve refs/directories, inherit child routes, validate directory ownership, detect raw-ID ambiguity, and implement compatibility versus strict mode. +4. Add envelope/route errors to `src/agent-manager/types.ts` and the mirrored webview type file. Add a coordinator adapter in `AgentManagerProvider` that derives the pinned project at panel attach, wraps all legacy inbound messages, stamps context output, and switches strictness based on exposed-project count. In this slice only the pinned context is activated, so rendered behavior remains unchanged. +5. Extend the `SessionProvider` adapter (`host.ts`, `vscode-host.ts`) with project-aware `setSessionRoute`, `trackSession`, `getSessionInfo`, `loadMessages`, `share`, and `unshare` methods. Retain temporary legacy methods only as single-project adapters, not as a second implementation. +6. Modify `KiloProvider`, `kilo-provider/options.ts`, and the extracted helpers in `kilo-provider-utils.ts` so Agent Manager Local sessions also have explicit routes, unknown strict routes fail closed, child routes inherit project identity, and project activation invalidates project-scoped cache work by generation. Keep non-Agent-Manager provider behavior unchanged. +7. Update `extension.ts` auto-approve/session-directory lookup to ask the router/coordinator for the active or unique `SessionRef`; do not merge raw ID maps and choose the first match. +8. Remove unknown-worktree-to-root fallback in `terminal-routing.ts`, and inject the pinned project root into `createTerminalHost` instead of reading `workspaceFolders[0]` internally. + +Slice A is complete when the flag-disabled Agent Manager is visually and behaviorally unchanged, every current project session has an explicit project route, and unit tests prove a second registered route cannot be accidentally handled through pinned-root fallback. Do not expose Add Project until Slice B is complete. + +### Slice B: lazy multi-context runtime, still before UI rendering + +1. Add `src/agent-manager/project-context.ts` and move root-bound state/managers/handlers out of `AgentManagerProvider`. Keep context code VS Code-free. Reduce the provider cap to the new rounded-up line count instead of raising it. +2. Make `AgentManagerProvider` the coordinator described above. Add cold/initializing/ready/suspended/disposed state, generation guards, expansion/activation APIs, shared semaphore, and per-project message dispatch. +3. Refactor status/tool/orchestration subscriptions so the coordinator routes once by project/directory. Do not retain per-context global subscriptions that can reject each other's requests. +4. Make terminal management panel-global and key mappings by composite session/worktree refs. A context supplies only an already-validated CWD. Ensure context collapse cannot switch to another project's terminal by raw ID. +5. Refactor session refresh to accept one project at a time and emit project-stamped results. Register routes for discovered Local sessions before they can be opened. Preserve failed-directory session IDs only within the affected project. +6. Change `WorktreeManager`'s mutation lock key to canonical common Git directory and reject adding another exposed context with the same common directory. +7. Add backend catalog commands (`requestProjects`, `addProject`, `removeProject`, `expandProject`, `collapseProject`, `activateProject`) behind the flag. These are testable protocol endpoints but need not yet have visible controls. + +### Slice C: first usable flagged UI + +After A and B are green, implement project accordions and the project selector in the New Worktree modal. Keep the current markup/layout when only the pinned project exists. Use composite refs in webview stores/tabs, one shared tab bar/detail pane, and clearly show the project name on cross-project session tabs and share actions. This UI slice is intentionally outside the backend-first scope. + +## Exact tests + +Add or update these focused tests under `packages/kilo-vscode/tests/unit/`: + +- `agent-manager-project-registry.test.ts`: pinned project is always first/fresh; persisted active cannot replace it; additional roots survive restart; pinned duplicate is suppressed; add canonicalizes subdirectories/symlinks; duplicate root/common Git dir and wrong authority are rejected; flag-off snapshot exposes only pinned without mutating catalog; catalog listing performs no resolver/filesystem calls; missing entries remain stored. +- `agent-manager-project-router.test.ts`: every Local/worktree session has a route; matching refs resolve; wrong project, unknown session, unknown worktree, missing project, and raw-ID collision fail closed; child inherits parent project/directory; compatibility inference works only for one exposed project; share and unshare use the same explicit directory. +- `agent-manager-project-context.test.ts`: collapsed cold projects construct/read nothing; concurrent expand initializes once; two expanded contexts use separate state roots and share the concurrency gate; collapse stops pollers and flushes; collapse during init suppresses posts; disposed-generation results cannot update a replacement context; remove is blocked while busy. +- Extend `kilo-provider-session-refresh.test.ts`: lists per project instead of merging project roots; outputs project stamps; failed worktree listing preserves only that project's sessions; activation generation drops stale config/session results. +- Extend `kilo-provider-worktree-context.test.ts`: strict unknown session/worktree has no workspace fallback; Local routes explicitly resolve to their own project root; a secondary Local session cannot resolve to pinned root. +- Extend `AgentManagerProvider.spec.ts`: single-project legacy messages are wrapped pinned; two-project unqualified messages return `project_required`; mismatched session/project returns `project_mismatch`; status/tool events dispatch by directory; restart selects pinned Local. +- Add terminal routing coverage: unknown project/worktree produces an error and no PTY/VS Code terminal; identical raw session IDs in different projects cannot select the wrong terminal. +- Extend `agent-manager-arch.test.ts`: new domain modules do not import `vscode`; lower `AgentManagerProvider.ts` maxLines after extraction. + +Run from `packages/kilo-vscode/` after each slice: + +```sh +bun test tests/unit/agent-manager-project-registry.test.ts tests/unit/agent-manager-project-router.test.ts tests/unit/kilo-provider-session-refresh.test.ts tests/unit/kilo-provider-worktree-context.test.ts +bun run typecheck +bun run lint +bun run test:unit +bun run knip +bun run check-kilocode-change +``` + +For Slice B, add the context/provider/terminal tests to the focused first command. No CLI/SDK regeneration is required because this architecture uses existing explicit-directory SDK parameters and adds only extension/webview protocol types. + +## Manual validation after the UI slice + +1. With the flag off and a non-empty stored catalog, restart VS Code. Agent Manager must look and behave exactly as today and use only the current window's Git root. +2. Turn the flag on, add a second repository, restart, and verify its header persists while the pinned current project remains first and active. Confirm the collapsed second project creates no output/state access/poll traffic until expanded. +3. Expand both projects, open Local and worktree sessions from each, switch rapidly, send prompts, answer permission/question requests, open terminals/diffs, and share then unshare. Verify every operation affects the displayed project's repository and config. +4. Disable the flag while a secondary project is active. Verify pinned Local becomes active, secondary state remains on disk/catalog, and re-enabling restores the descriptor without eager initialization. +5. Rename/remove a cataloged directory and restart. Verify the project remains as unavailable, cannot route actions to pinned root, and can be removed without touching its repository state. +6. Repeat in a remote window. Verify local/other-authority catalog entries are hidden but preserved and folder opening retains the remote URI authority. + +## Risks and rollback + +- The largest correctness risk is a hidden workspace-root fallback in `KiloProvider` or a helper. Treat every Agent Manager method that accepts `sessionID`, `worktreeId`, `draftID`, `requestID`, or directory as part of the routing audit. Tests should fail if strict mode calls `workspaceFolders[0]` for an identified operation. +- Project-scoped KiloProvider caches can show stale settings after a rapid project switch. Activation generation checks are required before posting and before replacing caches; merely cancelling transcript load is insufficient. +- Global event consumers can race. There must be exactly one ownership decision for orchestration/tool events, and directory must be retained for status/permission/question handling. +- Multiple contexts increase polling/process pressure. Share the concurrency semaphore, gate all pollers by panel visibility and expansion, and suspend on collapse/flag-off. +- Global-state updates from separate VS Code windows do not provide a transactional CAS. The first iteration should re-read before each serialized mutation and tolerate another window appearing after reload; do not build a filesystem lock or custom database for this feature. +- Rollback is safe: turn off the application flag. The pinned path and existing `.kilo/agent-manager.json` format remain intact, while the unused global catalog is preserved for a later re-enable. diff --git a/.kilo/plans/agent-manager-multi-project-shipping-gaps.md b/.kilo/plans/agent-manager-multi-project-shipping-gaps.md new file mode 100644 index 0000000000..8301929bbd --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-shipping-gaps.md @@ -0,0 +1,210 @@ +# 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. diff --git a/.kilo/plans/agent-manager-multi-project-uniform-ui.md b/.kilo/plans/agent-manager-multi-project-uniform-ui.md new file mode 100644 index 0000000000..5beb0e3ef2 --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-uniform-ui.md @@ -0,0 +1,740 @@ +# Agent Manager multi-project uniform UI architecture + +**Status:** Ready, corrected architecture; implementation pending + +**Date:** 2026-07-22 + +This plan supersedes the active-body plus background-summary UI described in `agent-manager-multi-project-runtime.md`. The existing project registry, immutable project-root concept, per-project state payloads, and project-tagged stats work are useful foundations. The current read-only `ProjectSummary` and active-project dynamic getter routing are not the target architecture. + +Configuration reads, writes, scopes, drafts, trust, revisions, and release-blocking tests are specified canonically in [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md). If this plan and that document differ on configuration behavior, the configuration document wins. + +The step-by-step implementation sequence from the current branch is [`agent-manager-multi-project-implementation-handoff.md`](./agent-manager-multi-project-implementation-handoff.md). Implementers should follow that handoff slice by slice rather than treating this architecture document as an unordered task list. + +## Product requirement + +Every expanded project permanently renders the real existing Agent Manager sidebar UI: + +- the real Local card; +- the real worktree cards, including their current menus and actions; +- the real session list, which may initially use a simplified layout but must contain real live session data; +- live git stats, PR state, run/setup state, stale state, and session state owned by that project. + +Selecting or interacting with a project may change the shared detail pane and top toolbar. It must not remove, replace, downgrade, or remount any expanded project body. There is no active-body versus background-summary rendering mode. + +The shared detail pane remains singular. The sidebar displays all expanded projects concurrently; the detail pane displays one selected project/local/worktree/session target at a time. + +## Architecture verdict on the current worktree + +The current implementation is a valid partial runtime foundation, but it is not safe to extend directly into fully interactive background cards. + +Keep and evolve: + +- `ProjectRegistry` and the pinned-workspace project concept; +- immutable root ownership in `ProjectContext`; +- `initContextState` extraction; +- project-stamped state/stats/PR messages; +- per-project webview stores; +- reusable `WorktreeItem`, `SectionHeader`, `WorktreeSectionActions`, and `UnassignedSessionsSection` components; +- the application-scoped feature flag. + +Replace or refactor before exposing interactive multi-project UI: + +- delete `ProjectSummary`; do not add more behavior to it; +- remove the rule that only `project.active` renders `renderBody()`; +- stop routing normal actions through `contexts.active()` and optional `projectId`; +- replace raw session/worktree/local UI keys with project-qualified references; +- replace the active singleton poller plus background poller split with uniform context-owned polling; +- make context initialization, mutation, suspension, and disposal explicit and generation-guarded; +- make the one embedded `KiloProvider` project/session-route aware. +- separate activation-bound runtime config from the Settings editor's immutable read/write binding. + +## Core model + +### Project identity + +A project is one canonical Git repository root in the current extension host. + +```ts +type ProjectId = string + +interface ProjectRef { + projectId: ProjectId +} + +interface WorktreeRef extends ProjectRef { + worktreeId: string +} + +interface SessionRef extends ProjectRef { + sessionId: string +} + +type SidebarTarget = + | { projectId: ProjectId; kind: "local" } + | { projectId: ProjectId; kind: "worktree"; worktreeId: string } + | { projectId: ProjectId; kind: "session"; sessionId: string } +``` + +Raw backend and repository-local IDs remain unchanged. Composite references are required at every panel/runtime boundary so identical local sentinels, worktree IDs, section IDs, or session IDs cannot cross projects. + +Project identity must include the extension-host URI scheme and authority in addition to the canonical root. Adding a project must also resolve canonical `git-common-dir` and reject another exposed project sharing that common Git directory. Linked worktrees of one repository cannot be registered as independent projects in the first release. + +### Active project means detail selection only + +`activeProjectId` is not a rendering mode and not an implicit mutation target. + +It means: + +- which project owns the current shared detail/chat/diff/terminal pane; +- which project the global top toolbar and global New Session/Run buttons target; +- which project/worktree directory supplies runtime-effective Kilo configuration in the one embedded `KiloProvider`. + +It never selects a Settings write target. Settings bindings are explicit and remain unchanged when detail selection changes. + +Project expansion controls rendering. Every `project.expanded` project renders one stable `ProjectSidebarBody` keyed by `projectId`, regardless of active status. + +### Atomic selection + +Do not send `selectProject` followed by an unqualified worktree/session action. VS Code message handlers are asynchronous and are not a transaction queue. + +Use one atomic selection intent: + +```ts +interface ActivateSelectionMessage { + type: "agentManager.activateSelection" + target: SidebarTarget +} +``` + +The coordinator validates the project and target, ensures the context is ready, activates the project-specific Kilo route, then emits one project-qualified activation result. The sidebar body stays mounted; only shared detail state changes. + +Mutations that do not need the detail pane, such as rename, delete, section edits, run, setup, open window, PR actions, or worktree creation, execute directly against their explicit `ProjectRef` or `WorktreeRef`. They do not activate another project as a side effect. + +## Configuration architecture + +See [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md) for the complete contract. + +### Verified blocking failure + +The current config protocol is unsafe for multi-project use: + +- `KiloProvider.fetchAndSendConfig()` resolves a mutable current directory and emits one unqualified `configLoaded` value (`packages/kilo-vscode/src/KiloProvider.ts:2513`). +- `ConfigProvider` owns one global/project/effective draft and sends an unqualified `updateConfig` (`packages/kilo-vscode/webview-ui/src/context/config.tsx:54`, `:243`). +- `KiloProvider.handleUpdateConfig()` resolves `getWorkspaceDirectory()` again at save time (`packages/kilo-vscode/src/KiloProvider.ts:2982`). A draft loaded for project A can therefore be written to project B after detail activation changes. +- The backend derives the project target from request directory and has no target or revision precondition (`packages/opencode/src/config/config.ts:944`, `packages/opencode/src/kilocode/config/config.ts:67`). Concurrent editors can overwrite one another, and a newly created higher-priority config file can redirect a pending write. +- Hidden scope splitting currently writes `commit_message` and `indexing.enabled` to the project while most fields go global (`packages/kilo-vscode/webview-ui/src/utils/config-scope.ts:3`). The UI does not make this target choice explicit. +- Project config writes also exist outside the save bar. In particular, provider disconnect can call `saveProject()` using the mutable provider workspace directory (`packages/kilo-vscode/src/provider-actions.ts:252`). Disabling only `handleUpdateConfig()` is insufficient. + +This is a release blocker, not a follow-up cleanup. + +### Product decision and staged order + +Preserve useful project-local editing such as `commit_message.prompt` and repository indexing rules. Indexing enablement is machine-local project consent, default off, and cannot be granted by repository config. Replace hidden active-directory targeting with explicit `User | Project` scope, a required Settings project selector for Project scope, an opaque immutable binding, and optimistic concurrency by target revision. + +Do not maintain one write model for flag-off mode and another for multi-project mode. Single-project mode uses the same explicit protocol, with a narrow adapter that injects the pinned `ProjectRef` for reads and open-file actions. + +### Separate runtime config from Settings editor config + +The current `ConfigProvider` conflates two different state machines. Split them: + +- **Runtime config** is read-only UI state for the selected detail/session route. It is keyed by `{ projectId, directory, activationGeneration }`, follows atomic detail activation, and drives chat, providers, agents, features, display, MCP, indexing, and sandbox behavior. +- **Settings editor config** is a scoped layer editor keyed by an immutable binding. It never follows detail activation and its draft is never applied optimistically to runtime config. + +Use distinct messages. Names may follow repository conventions, but the fields and invariants are mandatory: + +```ts +interface RuntimeConfigLoaded { + type: "runtimeConfigLoaded" + route: { + projectId: ProjectId + directory: string + activationGeneration: number + } + config: Config + features: FeatureFlags +} + +type ConfigScope = "global" | "project" + +interface ConfigTarget { + scope: ConfigScope + path: string + revision: string + exists: boolean + writable: boolean +} + +interface SettingsBinding { + id: string + connectionGeneration: number + scope: ConfigScope + project?: { + projectId: ProjectId + root: string + generation: number + } + directory: string + target: ConfigTarget +} + +interface ReadSettingsConfig { + type: "settingsConfig.read" + requestId: string + scope: ConfigScope + projectId?: ProjectId +} + +interface SettingsConfigSnapshot { + type: "settingsConfig.snapshot" + requestId: string + binding: SettingsBinding + preview?: { + projectId: ProjectId + root: string + generation: number + directory: string + } + targetConfig: Config + effective: Config + global: Config + project: Config + fields: Record + collections: Record +} + +interface WriteSettingsConfig { + type: "settingsConfig.write" + requestId: string + bindingId: string + set: Partial + unset: string[][] +} +``` + +For a global read, `projectId` is optional preview context used only to explain project shadowing. It never changes the global target. A project read requires `projectId` and always resolves the immutable `ProjectContext.root`, not the active worktree or session. + +For User scope, preview identity is presentation state, not part of the editable target identity. The controller keeps the global target binding and draft separate from the latest project preview. A clean preview change may replace both from one fresh snapshot. A dirty preview change updates only `preview`, effective/source metadata, and project raw data. It retains the original global target revision; if the fresh read reports a different global path or revision, mark the draft stale instead of silently rebasing it. + +`targetConfig` is the parsed raw content of the one file the API will patch. It is not the aggregate `global` or `project` layer. The form edits this raw target value so it never copies values inherited from another file into the target. The aggregate values remain in the snapshot solely for source, inheritance, and effective-preview UI. Existing JSONC comments remain server-side raw text and are preserved by patching; the webview does not round-trip serialized file contents. + +Parse `targetConfig` from raw JSONC without expanding `{env:}` or `{file:}` variables, so the Settings protocol never turns placeholders into secret values. If the exact target is syntactically or structurally invalid, return diagnostics and `writable: false` for form editing rather than treating it as `{}` and overwriting it. The recovery action is **Open file**. Source metadata must cover every path rendered by Settings, not only the current hand-maintained overlay field list. + +The extension owns an in-memory binding registry. Binding IDs are opaque, unpredictable, and scoped to one provider/webview lifecycle. The webview returns only `bindingId`, not a client-authoritative path or project envelope. On write the extension must: + +1. look up `bindingId` and reject unknown or expired bindings; +2. verify that the project still exists, has the same context generation, and remains trusted; +3. capture the stored binding before the first `await`; +4. send the stored directory and expected target to the backend without calling `getWorkspaceDirectory()`, `contexts.active()`, or any fallback; +5. route success or failure by `{ requestId, binding.id }` only. + +A binding expires after a successful save, backend reconnect, trust revocation, project removal, or context generation change. Success returns a new snapshot and binding. Cached snapshots are keyed by binding/scope/project identity; there is no singleton `cachedConfigMessage` for Settings. + +### Backend read/write and optimistic concurrency contract + +Evolve the Kilo-owned `/config/overlay` API rather than adding project identity to generic `Config.update`. The backend does not know Agent Manager `projectId`; it enforces the routed directory and filesystem target while the extension enforces project identity. + +`GET /config/overlay` continues to take explicit `directory` and `scope`, but returns: + +```ts +interface ConfigOverlaySnapshot { + context: { directory: string; worktree?: string } + scope: ConfigScope + targetConfig: Config + effective: Config + global: Config + project: Config + sources: ConfigSource[] + targets: { + global: ConfigTarget + project: ConfigTarget + active: ConfigTarget + } + fields: Record + collections: Record +} +``` + +The revision is a SHA-256 fingerprint of scope, canonical target path, existence marker, and exact file bytes. It is not an mtime. Exact bytes are required so comment-only JSONC edits and delete/recreate cycles with different content cause conflicts. The missing-file revision includes the intended canonical path. An ABA delete/recreate with identical bytes may retain the same revision; that state is content-equivalent and does not lose a user edit. + +`PATCH /config/overlay?directory=...` accepts exactly one scope per request: + +```ts +interface ConfigOverlayWrite { + scope: ConfigScope + set?: Record + unset?: string[][] + expected: { + path: string + revision: string + } +} + +interface ConfigOverlayWriteResult { + outcome: "applied" | "applied_but_overridden" + changed: boolean + overriddenPaths: string[][] + snapshot: ConfigOverlaySnapshot +} +``` + +The server performs the following transaction: + +1. Resolve the authoritative target from the routed instance directory and requested scope. Never accept `expected.path` as an arbitrary destination. +2. Canonicalize and compare the resolved and expected paths. Reject a changed target, including a newly created higher-priority config file. +3. Acquire a cross-process lock keyed by canonical target path. Project writes must use the same locking discipline already used for global config. +4. Inside the lock, re-resolve the target, read exact bytes, recompute the revision, and reject a mismatch. +5. Apply `set`/`unset` to the raw target layer, preserve JSONC comments where supported, validate the result, and atomically replace the target using a temporary file in the same directory. Do not expose a partially written config. +6. Invalidate the affected config instances, reload the overlay, and return the authoritative post-write snapshot. Do not fabricate optimistic config if refresh fails. +7. Emit a config event containing `scope`, routed `directory`, canonical `target`, and new `revision`. Global changes invalidate all runtime config caches; project changes invalidate only matching directory/project caches. + +`applied_but_overridden` is success. For each `set` leaf, the refreshed overlay identifies whether a higher layer still wins, such as project config shadowing a user value or managed/runtime config shadowing a project value. The UI keeps the saved value and explains why effective behavior did not change. + +Do not send global and project patches in one webview message or pretend that two files save atomically. User and Project scope maintain independent drafts and save independently. VS Code extension preferences such as autocomplete settings are a third, visibly separate store with their own request acknowledgements. + +Domain actions outside Settings must not perform client-side read-modify-write against a mutable effective config. Provider/custom-provider/work-style actions are User-global operations and should call either the same revision-aware User endpoint or a narrow backend mutation that merges named fields under the target lock. Permission Dock "always" rules remain a narrow global rule mutation tied to the exact pending request/session route; that session directory validates the request but does not select a project config target. All such mutations emit the revisioned config event, so a dirty Settings editor becomes stale rather than being overwritten. + +Expected typed failures are: + +- `unknown_project` or `stale_project_generation` from the extension; +- `untrusted_project` or `workspace_untrusted` from the extension; +- `binding_mismatch` or `binding_expired` from the extension; +- HTTP 409 `config_target_changed` with the current target descriptor; +- HTTP 409 `config_revision_conflict` with the current target descriptor and fresh snapshot when available; +- HTTP 403 `config_target_not_writable` or project target escaping its trusted root; +- HTTP 422 `config_invalid` with schema/parse details; +- an I/O failure with no success acknowledgement. + +Every failure preserves the draft and names the scope/project/path. A config event is never treated as confirmation of the user's save; only a matching write response may clear that draft. + +### Draft and project-switch behavior + +- Detail activation from project A to B does not change the Settings selector, binding, values, draft, save state, or target. +- A User Settings draft is global and survives all detail-project switches. +- Project scope uses an explicit trusted project selector that never follows detail activation automatically. +- On first open, the selector may initialize once from the pinned/selected project for convenience, but that choice is immediately captured as Settings state. +- Choosing another Settings project with a dirty draft prompts `Save`, `Discard`, or `Stay`. Drafts are never carried to a different project. +- Clean selector changes issue a new request and ignore out-of-order responses by `requestId`. +- A save captures one binding. Switching visible settings while it is in flight does not redirect it; the result updates only the original binding store and identifies that target in its notification. +- External config events refresh a clean binding. For a dirty or saving binding they mark it stale and show a changed-on-disk banner without replacing local edits. +- A 409 preserves the draft and disables blind retry. The first version offers `Reload`, `Open file`, and `Discard`; it does not auto-merge or force overwrite. A future three-way merge may use the original snapshot, fresh snapshot, and draft. + +### Scope UX + +The first release presents explicit `User | Project` scope, a required selector in Project scope, source badges, and separate binding-keyed drafts. The selected scope applies to the entire operation. Remove hidden `splitConfigByScope`; no setting silently chooses a different file. Inherited user values remain visible in Project scope with `Override`/`Reset to inherited` affordances backed by `set`/`unset`. + +### Project root, worktree, and trust semantics + +- A Project Settings binding targets the registered checkout root held by `ProjectContext.root`. It never targets the last selected Local/worktree/session card. +- Runtime config remains directory-correct: Local uses the project root, and a worktree session uses its exact routed worktree directory. Branch-specific config in a managed worktree can therefore differ at runtime. +- Editing a project's root config does not claim to update already-created worktree checkouts. Editing a worktree config from Settings is out of scope for the first Project Settings release. A future worktree editor must use an explicit `WorktreeRef` and its own immutable binding. +- The backend resolver may choose an existing root or `.kilo`/`.kilocode` config according to current precedence, but the expected target must remain within the trusted project checkout after canonical/symlink resolution. A symlink escape is read-only and must not be patched through the API. +- For an existing target, canonicalize the file with `realpath`. For a missing target, canonicalize its nearest existing ancestor and append the remaining path components before checking containment. Global writes must similarly remain under the canonical `Global.Path.config` root. Do not rely on a lexical prefix check. +- Additional Agent Manager projects must be registry-trusted before project overlay evaluation, opening/creating project config, or project writes. Global User Settings remain editable without trusting a project. +- The pinned project is trusted for config evaluation only when the VS Code workspace is trusted. Do not treat `pinned` as an unconditional trust grant. +- Trust is stored outside the repository in the extension registry/global state. A project config file cannot grant its own trust. Revoking trust invalidates all bindings and project runtime caches. + +### Concrete implementation sequence for config safety + +1. In `packages/opencode/src/kilocode/config/overlay.ts`, replace path-only targets with revisioned target descriptors and add generic changed-path source resolution. +2. Add a Kilo-owned compare-and-swap writer under `packages/opencode/src/kilocode/config/` that resolves, confines, locks, fingerprints, patches, validates, and atomically replaces one target. Keep shared `packages/opencode/src/config/config.ts` changes to minimal marked delegation/invalidation hooks. +3. Update `packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts` and `handlers/config-console.ts` with the write precondition, result, and typed error contracts. Regenerate `packages/sdk/js/`. +4. Extract a config binding controller under `packages/kilo-vscode/src/kilo-provider/` and have `KiloProvider.ts` delegate reads/writes to it. The controller receives explicit project-route resolution; it never reads mutable active workspace state during a bound operation. +5. Replace `configLoaded`, `configUpdated`, and unqualified `updateConfig` in `webview-ui/src/types/messages/` with runtime and Settings message families. Keep a temporary single-project adapter only at the protocol edge. +6. Split `webview-ui/src/context/config.tsx` into activation-bound runtime state and binding-keyed Settings editor state. Settings components consume the editor context; non-settings consumers continue to use runtime-effective config. +7. Remove `webview-ui/src/utils/config-scope.ts` after migrating every control to explicit scope. Preserve explicit project editing for commit messages and repository indexing rules; move indexing enablement to machine-local project consent. +8. Audit all extension config mutators and route user-config imports, resets, custom providers, work-style writes, project writes, and save-bar writes through a revision-aware endpoint or backend atomic field-level mutation. A project-sourced item must never fall back to an active directory write. +9. Make open-file actions take `ProjectRef`, resolve `ProjectContext.root`, verify trust, and show the exact path. For a missing target, open an unsaved document at that explicit path or require an explicit create action; never create it merely by visiting Settings. +10. Update config events and cache invalidation so scope/directory/revision are retained end to end. + +Focused tests belong in: + +- `packages/opencode/test/kilocode/server/config-overlay.test.ts` for raw target snapshots, target changes, 409 revisions, source shadowing, locks, symlink confinement, and response outcomes; +- `packages/opencode/test/kilocode/project-config-update.test.ts` for target selection and atomic writes from repository roots/nested directories; +- a new Kilo-owned backend unit test beside those files only if the compare-and-swap writer needs direct fault-injection coverage; +- `packages/kilo-vscode/tests/unit/config-utils.test.ts`, replacing singleton `ConfigState` assumptions with binding/request-aware draft tests; +- `packages/kilo-vscode/tests/unit/config-scope.test.ts`, deleted when `splitConfigByScope` is removed and replaced by tests proving one explicit scope per save; +- Agent Manager route/context tests for immutable project-root resolution, trust revocation, removal, and generation changes; +- KiloProvider protocol tests for A-to-B activation during reads and writes, out-of-order responses, cache partitioning, and auditing non-save-bar mutators. + +## One `.kilo/agent-manager.json` per project + +Yes, every project has its own repository-local state file: + +```text +project-a/.kilo/agent-manager.json +project-b/.kilo/agent-manager.json +project-c/.kilo/agent-manager.json +``` + +This is already structurally supported because `WorktreeStateManager` derives its file from its immutable constructor root. The final architecture makes this ownership explicit and safe. + +### Ownership rules + +Each `ProjectContext` owns exactly one: + +- canonical repository root and canonical Git common directory; +- `WorktreeStateManager(root)` writing only `/.kilo/agent-manager.json`; +- `WorktreeManager(root)`; +- `SetupScriptService(root)` and project run service; +- diff/import/branch-naming services; +- git stats and PR pollers; +- worktree/session/section/order/run/stale caches; +- mutation queue and initialization promise. + +The global project registry stores descriptors only. It never stores worktrees, sections, sessions, tab order, or repository behavior. The pinned project is derived from the current workspace and remains outside the registry. + +### Initialization + +The first explicit expansion of a trusted project calls one single-flight `ensureReady()` promise: + +1. validate root and Git common directory; +2. construct project-owned services; +3. update that repository's local Git exclude; +4. load only that repository's `.kilo/agent-manager.json`; +5. discover and reconcile only that repository's worktrees; +6. register Local and worktree session routes for that project; +7. start context-owned polling if the project is expanded and the panel is visible; +8. post one generation-stamped project snapshot. + +Repeated expansion must reuse the same initialization promise. Collapse/removal during initialization records the desired lifecycle transition and prevents late completion from posting or mutating a replacement context. + +### Writes and disposal + +All mutations for one project execute through that context's serialized mutation queue. An operation captures the context and generation before its first await and never re-resolves through `contexts.active()` afterward. + +Collapse: + +- leaves the project registered; +- stops pollers/watchers after current operations reach a safe boundary; +- flushes its own state file; +- retains session routes required for already-running backend sessions; +- never aborts a session merely because the UI collapsed. + +Removal: + +- never deletes `.kilo/agent-manager.json`, worktrees, branches, or sessions; +- refuses removal while a project mutation/run requires ownership, unless an explicit coordinated shutdown is implemented; +- awaits context disposal and state flush before deleting the registry descriptor; +- invalidates context generation before awaits so late callbacks are ignored. + +Panel disposal awaits all initialized contexts before shared services are disposed. + +Two VS Code windows writing the same repository state file remain the same pre-existing concurrency boundary as today's single-project Agent Manager. This plan prevents one panel from creating two contexts for the same Git common directory; cross-window file coordination is not expanded in this feature. + +## ProjectContext lifecycle + +`ProjectContext` becomes a lifecycle owner rather than only a lazy service container. + +```ts +type ProjectLifecycle = + | "cold" + | "initializing" + | "ready" + | "suspended" + | "disposing" + | "disposed" + +interface ProjectContext { + readonly id: ProjectId + readonly root: string + readonly commonGitDir: string + readonly generation: number + ensureReady(): Promise + run(operation: (ctx: ReadyProjectContext) => Promise): Promise + suspend(): Promise + dispose(): Promise +} +``` + +Every async init, poll, mutation, diff, run, import, setup, PR update, branch naming, and session operation checks context identity and generation before committing state or posting to the webview. + +## Strict project-qualified protocol + +When multi-project mode exposes more than one project, every repository operation requires project identity. + +Required project qualification includes: + +- worktree create/delete/rename/import/order/section/open/PR actions; +- Local and worktree session create/open/close/fork/promote/share/unshare actions; +- prompt, abort, permission, question, command, sandbox, and context actions; +- terminal create/input/resize/close/show actions; +- diff/watch/apply/revert actions; +- run/setup/configuration actions; +- all state/session/diff/run/terminal/setup/PR output messages. + +Missing, unknown, mismatched, inactive-when-required, or ambiguous identities return a typed route error. They never fall back to the current project or `workspaceFolders[0]`. + +Single-project mode retains a narrow compatibility adapter that injects the pinned project identity. Core handlers always operate on explicit references. + +## One KiloProvider with a project/session route service + +Keep one embedded `KiloProvider`. Multiple providers would duplicate event handling, terminal registrations, caches, and current-session state for one webview. + +Add an Agent Manager route service: + +```ts +interface SessionRoute extends SessionRef { + directory: string + generation: number +} +``` + +It must: + +- register explicit routes for every Local session at its project root; +- register every worktree session at its worktree path; +- let child sessions inherit the parent route; +- validate directory ownership; +- detect ambiguous raw session IDs; +- supply the exact directory to every SDK operation, including share/unshare; +- remove current-root and workspace-root fallback for identified Agent Manager sessions; +- refresh sessions independently for every initialized project and emit real project-stamped `SessionInfo` records. + +The shared detail pane consumes the selected `SessionRef`. Every sidebar body consumes its project's real session store. Background session UI may initially be visually simpler, but it must not display fabricated titles or placeholder session objects. + +Project activation in `KiloProvider` must invalidate and refresh project-scoped config, providers, agents, commands, skills, MCP state, indexing, sandbox state, and Git status under an activation generation guard. + +## Global services with composite keys + +Some services remain panel-global to avoid duplicate VS Code registrations, but all associations use composite references. + +- terminal mappings: `ProjectId + SessionId` or `ProjectId + WorktreeId`; +- run state: keyed by `WorktreeRef`, including project Local; +- visible/presence state: keyed by `SessionRef`; +- tabs and sidebar selection: keyed by `SidebarTarget`; +- diff/apply/revert state: keyed by `ProjectRef` and target; +- pending setup/create/delete state: keyed by project-qualified resource refs. + +Remove unknown-worktree-to-current-root terminal fallback. A context supplies a validated CWD to the global terminal manager. + +Global backend events are routed once by event directory and registered session route. Status, tool, orchestration, permission, and question events retain their directory and enter exactly one owning project context. A cold/collapsed context is not initialized by an unsolicited event. + +## Uniform project sidebar UI + +### One body implementation + +Extract the existing real `renderBody()` from `AgentManagerApp.tsx` into `ProjectSidebarBody`. Do not maintain active and background implementations. + +`ProjectSidebarBody` receives a stable project-scoped store and project-qualified action callbacks. It reuses: + +- the existing Local card markup; +- `WorktreeSectionActions`; +- `WorktreeItem`; +- `SectionHeader` and current drag/drop behavior; +- `UnassignedSessionsSection`; +- existing rename/delete/open/PR/run/setup/session affordances. + +Render it for every expanded project: + +```tsx + + {(project) => ( + + + + + + )} + +``` + +The body is keyed by `projectId`, not active status. Changing detail selection cannot remount it. + +### Per-project webview stores + +Replace active-only shared sidebar signals with `Map` or an equivalent Solid store registry. + +Each store owns: + +- state/worktrees/sections/order/stale state; +- real `SessionInfo` and managed-session state; +- git stats and local stats; +- PR and run/setup state; +- pending delete/rename/create state; +- sidebar search/order/collapse state where repository-owned; +- generation for dropping stale output. + +Only shared detail/chat/terminal state remains global. The top toolbar reads the selected detail target's project store. + +`project-live.ts` may be evolved into this complete store registry. It must stop mirroring active payloads into a separate sidebar signal set. + +### Scrolling and layout + +The project list becomes the sidebar scroll container. Individual project bodies must not use a global `50vh` cap that makes two projects fight for viewport height. Section and project collapse state controls density; normal browser scrolling exposes all expanded worktrees. + +### UI behavior + +- Clicking a Local/worktree/session card emits one atomic project-qualified selection. +- Rename/delete/run/open/section/PR actions execute against the clicked card's project without changing sidebar structure. +- The active project may receive an emphasis style, but no different markup. +- Active project chevrons are not disabled; expansion and detail selection are separate concepts. +- Global New Session/Run buttons target the last selected detail project. +- New Worktree accepts an explicit target project, defaulting to the selected detail project. + +## Uniform polling and live state + +Every ready expanded context owns the same poller set. There is no active singleton poller plus background poller split. + +Pollers emit `{ projectId, generation }` and update only their owning context/store. Stop/suspend increments generation before clearing timers so in-flight Git/PR results are dropped. + +Context state is pushed after every project mutation, not only initial expansion or active-project changes. This includes worktree/session/section/order/run/setup changes in background projects. + +Panel visibility changes poll cadence uniformly across all expanded contexts. + +## Registry correctness + +Registry mutations use one in-process queue. Each mutation re-reads persisted storage, validates it, applies one mutation, writes once, then updates memory. Trust/remove persistence failures are user-visible and do not leave UI state ahead of storage. + +Registry descriptors include canonical root, canonical Git common directory, URI scheme/authority, order, label, trust, and added time. They do not contain Agent Manager repository state. + +## Implementation order + +### Slice 0: configuration safety blocker + +1. Introduce revisioned `/config/overlay` reads and compare-and-swap writes, first for User scope and with the same generic implementation covered for Project scope. +2. Separate runtime-effective config messages/state from immutable Settings bindings and drafts. +3. Add explicit `User | Project` Settings scope and an immutable trusted project selector/binding for Project writes. +4. Remove hidden `splitConfigByScope` persistence and audit every direct/indirect config mutation, including provider disconnect, import/reset, custom providers, work-style presets, Permission Dock, and indexing. +5. Make config events scope/directory/target/revision aware and generation-guard runtime activation refreshes. +6. Add target-switch, stale-binding, revision-conflict, trust, and draft-preservation tests. + +Do not enable multi-project broadly before this slice passes. + +### Slice 1: routing and identity blockers + +1. Add project-qualified refs and strict protocol types. +2. Add the project/session route service and explicit Local routes. +3. Remove Agent Manager current/workspace-root fallback for identified operations. +4. Add atomic project-qualified selection. +5. Convert panel-global maps to composite keys. +6. Add strict routing and raw-ID collision tests. + +Do not expose interactive background controls before this slice passes. + +### Slice 2: context lifecycle and service ownership + +1. Add single-flight initialization, lifecycle state, generation, and mutation queue to `ProjectContext`. +2. Move root-bound diff/import/run/setup/branch-naming/poller ownership behind context APIs. +3. Replace dynamic active getters inside async mutations with captured contexts. +4. Add suspend/remove/dispose coordination. +5. Reject duplicate Git common directories and serialize registry writes. +6. Route global backend events by directory. + +### Slice 3: complete per-project stores + +1. Refresh and store real sessions per project. +2. Emit all relevant output with project and generation. +3. Evolve `project-live.ts` into a complete project sidebar store registry. +4. Make polling uniform and generation-safe. +5. Add background mutation/state propagation tests. + +### Slice 4: uniform real UI + +1. Extract the current `renderBody()` intact into `ProjectSidebarBody`. +2. Parameterize it by one project store and project-qualified callbacks. +3. Render one body per expanded project. +4. Delete `ProjectSummary` and its CSS. +5. Separate project expansion from detail activation. +6. Add project-aware New Worktree targeting. +7. Add component/visual tests proving no body remount or structural change on selection. + +### Slice 5: validation and release gating + +1. Run focused lifecycle/router/context tests after each slice. +2. From `packages/opencode/`, run the focused config overlay/project-update tests and CLI typecheck. Run the opencode annotation and Promise-facade guards for shared-file hooks. +3. Regenerate the JS SDK after endpoint schema changes and verify generated consumers compile; never hand-edit generated sources. +4. From `packages/kilo-vscode/`, run typecheck, lint, focused/unit tests, knip, compile/package, and architecture guards. +5. Manually exercise two repositories with multiple worktrees and sessions in both, including a dirty User draft during project activation, an external config edit conflict, project trust revocation, project removal, and project-config open targeting. +6. Keep the feature flag default off until all acceptance criteria pass. + +## Blocking tests + +The feature is not complete until tests prove: + +1. Two concurrent expands initialize one context once. +2. Collapse/remove during initialization produces no late state or poller posts. +3. A project switch during create/import/setup/session/run/diff cannot change operation ownership. +4. Project B Local sessions never resolve through project A. +5. Missing/mismatched project references fail closed. +6. Poller/PR late results after stop or generation change are dropped. +7. Same raw worktree/session/local IDs in two projects do not collide in runtime or UI stores. +8. Two expanded projects render two permanent full `ProjectSidebarBody` instances. +9. Selecting a card changes only shared detail state and active emphasis, not body structure or mount identity. +10. Every worktree/session/terminal/diff/run/setup/section/PR action targets the clicked project's context. +11. Registry writes preserve concurrent add/remove/trust changes. +12. Roots sharing one Git common directory cannot both register. +13. Panel shutdown awaits all contexts and stops background activity. +14. Flag-off behavior remains the current single-project UI and state file. +15. A Settings draft loaded for project A cannot write project B after any activation, expansion, removal, trust, or worktree switch. +16. User Settings writes target the revisioned user file; explicit Project Settings writes target only the selected trusted project's revisioned root config. No key is silently rerouted. +17. A clean external config change refreshes Settings; a dirty or saving binding is marked stale without losing its draft. +18. Concurrent editors using the same revision produce one success and one 409 conflict, with no lost update or partial file. +19. A comment-only JSONC edit, file deletion/recreation with changed bytes, or newly created higher-priority config changes the revision/target and rejects the stale write. +20. A save response clears only the matching `{ requestId, binding.id }`; an SSE config event or out-of-order read cannot confirm a save. +21. Global and project writes are independently acknowledged and never presented as one atomic transaction. +22. Project config open/read/write actions reject unknown, untrusted, removed, generation-stale, symlink-escaping, or mismatched project targets. +23. Runtime config for a selected managed worktree uses that worktree directory, while Project Settings/open-file targets the immutable registered project root. +24. Project-sourced providers and other config collections cannot trigger an implicit active-project write from Settings actions. + +## Acceptance criteria + +- No `ProjectSummary` or alternate fake-card body exists. +- Every expanded project displays real live Local, worktree, and session UI concurrently. +- Every visible worktree/session action is usable and routes by explicit project identity. +- Switching detail selection causes no sidebar body remount, replacement, collapse, or data reset. +- Each project reads and writes only its own `.kilo/agent-manager.json`. +- Expanded projects remain live independently; collapsed projects suspend safely. +- No identified operation falls back to active/current/workspace root. +- Single-project behavior and persisted state remain backward-compatible when the flag is off. +- Shared Settings exposes explicit User and Project scope; Project writes require a trusted project selector and immutable binding. +- Runtime project activation and Settings editor binding are separate state machines. +- Every Settings config write has an immutable scope/target binding and revision precondition; narrow domain mutations merge named fields under the same target lock. No config mutation uses a save-time active-directory lookup. +- Dirty drafts survive external updates and conflicts and can never migrate between projects. +- Project Settings targets the explicit registered root, never the active Agent Manager project or active worktree. + +## Planned UI + +```text +┌─ Sidebar ─────────────────────────────────┬─ Shared detail pane ──────────┐ +│ ← selected project search calendar gear │ │ +│ │ Chat / diff / terminal │ +│ PROJECTS + │ for the selected │ +│ ▾ abalone-bactrosaurus │ project/worktree/session │ +│ ┌─────────────────────────────────┐ │ │ +│ │ Local main +3295 -548 ↓54 │ │ │ +│ └─────────────────────────────────┘ │ │ +│ WORKTREES actions │ │ +│ ┌─────────────────────────────────┐ │ │ +│ │ feature-x #1234 +120 -8 ↓2 │ │ │ +│ │ real menus, rename, run, open │ │ │ +│ └─────────────────────────────────┘ │ │ +│ ┌─────────────────────────────────┐ │ │ +│ │ bugfix-y +45 -12 │ │ │ +│ └─────────────────────────────────┘ │ │ +│ SESSIONS │ │ +│ • Fix login redirect running │ │ +│ • Refactor auth │ │ +│ │ │ +│ ▾ kilo-pi-provider │ │ +│ ┌─────────────────────────────────┐ │ │ +│ │ Local master +16 -6 ↓1 │ │ │ +│ └─────────────────────────────────┘ │ │ +│ WORKTREES actions │ │ +│ ┌─────────────────────────────────┐ │ │ +│ │ provider-z #88 +88 -3 │ │ │ +│ │ real menus, rename, run, open │ │ │ +│ └─────────────────────────────────┘ │ │ +│ SESSIONS │ │ +│ • Add provider tests │ │ +│ │ │ +│ ▸ docs-repo collapsed │ │ +│ │ │ +│ [New Session] [Run] │ │ +└───────────────────────────────────────────┴──────────────────────────────┘ + +Selecting any card: + +- keeps every project body mounted and pixel-stable; +- changes only the selected styling, top toolbar target, and shared detail pane; +- never swaps a real body for a summary or vice versa. +``` diff --git a/bun.lock b/bun.lock index 1ccc20ef84..4efc3fec0e 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.16", + "version": "7.4.17", "bin": { "opencode": "./bin/opencode", }, @@ -40,20 +40,20 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.93", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/gateway": "3.0.157", "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.88", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/perplexity": "3.0.26", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/provider": "3.0.14", + "@ai-sdk/provider-utils": "4.0.40", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", @@ -127,7 +127,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -141,7 +141,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "effect": "catalog:", }, @@ -153,7 +153,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -174,7 +174,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -197,7 +197,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -227,12 +227,12 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.88", "@ai-sdk/openai-compatible": "2.0.48", "@clack/prompts": "1.0.0-alpha.1", "@kilocode/plugin": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.16", + "version": "7.4.17", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -273,7 +273,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -305,11 +305,11 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.15", + "version": "7.4.16", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -323,7 +323,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -338,7 +338,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -352,7 +352,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -390,7 +390,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -459,7 +459,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -476,7 +476,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -494,7 +494,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.16", + "version": "7.4.17", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -506,20 +506,20 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.93", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/gateway": "3.0.157", "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.88", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/perplexity": "3.0.26", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/provider": "3.0.14", + "@ai-sdk/provider-utils": "4.0.40", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", @@ -661,7 +661,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -689,7 +689,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -703,7 +703,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "semver": "^7.6.3", }, @@ -714,7 +714,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "cross-spawn": "catalog:", }, @@ -729,7 +729,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -743,7 +743,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.16", + "version": "7.4.17", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -766,7 +766,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -793,7 +793,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.16", + "version": "7.4.17", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -846,22 +846,22 @@ }, }, "trustedDependencies": [ - "web-tree-sitter", "esbuild", - "tree-sitter-bash", "protobufjs", + "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", @@ -917,7 +917,7 @@ "@types/node": "24.12.4", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20260316.1", - "ai": "6.0.168", + "ai": "6.0.235", "cross-spawn": "7.0.6", "diff": "8.0.4", "dompurify": "3.4.2", @@ -970,7 +970,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.93", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.50", "@ai-sdk/openai": "3.0.88", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9nKNmRtopjE3+L+286/4sLBDfDvmdsL7fiQXG0TE38NywxkDDLUhdr0KSllkRQ+m1/8bO7x4rIVhHSjQQiqmnQ=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.54", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m+oLYlZUA0j5aeE8BrjDd2DSCx6HujqPB2C0YjQF7iGQ4sTCCmhHfH2o0uY7vlSPIJpA2ak6YyF7R14a5dq8fw=="], @@ -980,13 +980,13 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IkBQPEfRUbPEcWzigT76KttqoDqM4FR8H8cp5pgAw5RdDN++nUoJa0uZan3LuOp4IC8BKGZABJCqB97CMIYLdQ=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pnl22aGBIdpK0sXSJN4zDykIXTdyO9m3gFhno3SBPpdwT4Tq041Uy0i/EDWSxIiFa9JEokFGiCt4NNzcE5ntRA=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KjgM2IDMwm1qTKaW2n2qCr2ok9/wlhUC9ggNvyYYeCupEEGR8Gki1oFRA16PsBcyEr98k+kaKw4cF6D0XaVWWA=="], "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.55", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.50", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cdlFOq4NuwZdgdwocae5ItT7uKh3mf4junJWGVKtOcs7ATY42VZLzIU2MFBgm2imGVZXwB6kcl3jd9sDBnJ8cw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.157", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jO0LUsIQC+FeJRQI1KFiNcfTGp6a0lXrymekaXfBIvhVEOyt0NUyc9wiVi/dYGHQmWFXUo8QgmghgGAGLOPTAA=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.73", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g=="], @@ -996,15 +996,15 @@ "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.88", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6M4+bxK/UMijDYK2ia1hCKYma1iajFYxHDlhbinFUdhH2WbFsjoZRMpSjEMlTOBptqmh2J1JclKnprGulF8WwQ=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="], "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="], @@ -2670,7 +2670,7 @@ "aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="], - "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], + "ai": ["ai@6.0.235", "", { "dependencies": { "@ai-sdk/gateway": "3.0.157", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Uyeuvowo20XChpFC0lUaQq8xf0Kg7OElA56fEjUYoeWMJIzqshDTz4S57KXobcJ4m+iDQxMeJoUj06CIxSLx7A=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], @@ -4756,6 +4756,10 @@ "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@ai-sdk/alibaba/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/alibaba/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], "@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="], @@ -4768,14 +4772,12 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/cohere/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -4784,12 +4786,10 @@ "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], + "@ai-sdk/deepinfra/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA=="], - "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA=="], @@ -4812,24 +4812,34 @@ "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/perplexity/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], + "@ai-sdk/togetherai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], + "@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], @@ -5076,8 +5086,6 @@ "@kilocode/kilo-docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@kilocode/kilo-gateway/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], - "@kilocode/kilo-indexing/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@kilocode/kilo-indexing/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -5092,6 +5100,8 @@ "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@morphllm/morphsdk/ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], + "@morphllm/morphsdk/openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], "@npmcli/arborist/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -5346,10 +5356,16 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + + "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IkBQPEfRUbPEcWzigT76KttqoDqM4FR8H8cp5pgAw5RdDN++nUoJa0uZan3LuOp4IC8BKGZABJCqB97CMIYLdQ=="], + "ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="], "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.92", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yqRMdEVfSkZCXs5mqeKfYOuQElo93igwz/4zWbYkhZWAW7tDh+5uqRef6MtQVYF/bTC1EsdbeO9DtsOtAZMOLA=="], "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -5700,12 +5716,12 @@ "@actions/artifact/@actions/core/@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="], + "@ai-sdk/alibaba/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5714,8 +5730,6 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5854,6 +5868,12 @@ "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "@morphllm/morphsdk/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], + + "@morphllm/morphsdk/ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@morphllm/morphsdk/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@morphllm/morphsdk/openai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@npmcli/package-json/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -6042,8 +6062,38 @@ "@vscode/vsce/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], @@ -6296,6 +6346,8 @@ "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@morphllm/morphsdk/ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@octokit/graphql/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], @@ -6382,6 +6434,20 @@ "@vscode/test-cli/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 8f0a833b46..414454b412 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Y64ujq2R4SiyOavsXmaYsQkVNfdjhkzmPolOEV1RVyc=", - "aarch64-linux": "sha256-7S209ir2j9o9NXYmMUnEfo6m64cUIkexd8L95UeCMFE=", - "aarch64-darwin": "sha256-bE1zw7EK+4rgUgw80t8hdc2yojAF2f8ihjorh6B7jJE=", - "x86_64-darwin": "sha256-xukM9cEvZAKCXewun9xReGBKz09i63LACLN/eH54020=" + "x86_64-linux": "sha256-sid/FCql4CmiAb1MNGMH33fwNs6esv9UNC819Yb03bQ=", + "aarch64-linux": "sha256-1H1873nLqmcKrumAjnjPPts5+EyJLBm1Z5VKsOYANeg=", + "aarch64-darwin": "sha256-i7D5RSPIVxXNY1gUnsYdFhVU2FxYEcjqYcQx6zZrJjg=", + "x86_64-darwin": "sha256-5lSTqolFVcaKqN6ndKGJLcCUP8JQ8i60np+3eAulnZg=" } } diff --git a/package.json b/package.json index 2cbfccad00..9c5567bfd5 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", "effect": "4.0.0-beta.74", - "ai": "6.0.168", + "ai": "6.0.235", "cross-spawn": "7.0.6", "hono": "4.12.12", "hono-openapi": "1.1.2", @@ -171,6 +171,6 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.16", + "version": "7.4.17", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index ddb4d33d35..35301f1ac3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -85,20 +85,20 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.93", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/gateway": "3.0.157", "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.88", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/perplexity": "3.0.26", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/provider": "3.0.14", + "@ai-sdk/provider-utils": "4.0.40", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 554bea4892..0591311f1c 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -4,6 +4,7 @@ import * as NodePath from "@effect/platform-node/NodePath" import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change +import { settle } from "./kilocode/exit-code" // kilocode_change - settle signal termination as 128 + signum import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" @@ -441,16 +442,7 @@ export const make = Effect.gen(function* () { getInputFd: fd.getInputFd, getOutputFd: fd.getOutputFd, isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), - exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { - if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) - return Effect.fail( - toPlatformError( - "exitCode", - new Error(`Process interrupted due to receipt of signal: '${signal}'`), - command, - ), - ) - }), + exitCode: Effect.flatMap(Deferred.await(signal), settle), // kilocode_change - signal termination settles as 128 + signum kill: (opts?: ChildProcess.KillOptions) => { const sig = opts?.killSignal ?? "SIGTERM" const send = (s: NodeJS.Signals) => diff --git a/packages/core/src/kilocode/exit-code.ts b/packages/core/src/kilocode/exit-code.ts new file mode 100644 index 0000000000..4a4408a7b4 --- /dev/null +++ b/packages/core/src/kilocode/exit-code.ts @@ -0,0 +1,16 @@ +import { constants } from "node:os" +import * as Effect from "effect/Effect" +import * as Predicate from "effect/Predicate" +import { ExitCode } from "effect/unstable/process/ChildProcessSpawner" + +// A process terminated by a signal produces a null exit code (see the "exit" +// event on node:child_process). Report the conventional 128 + signum code +// (e.g. 139 for SIGSEGV) instead of failing, which left consumers like the +// bash tool waiting on a numeric code that never arrived. +export const settle = ([code, signal]: readonly [code: number | null, signal: NodeJS.Signals | null]) => { + if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) + if (Predicate.isNotNull(signal) && signal in constants.signals) { + return Effect.succeed(ExitCode(128 + constants.signals[signal])) + } + return Effect.succeed(ExitCode(1)) +} diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts new file mode 100644 index 0000000000..bb1fd30be7 --- /dev/null +++ b/packages/core/src/kilocode/pty/termination.ts @@ -0,0 +1,179 @@ +import { spawn } from "child_process" +import { setTimeout as sleep } from "node:timers/promises" +import type { Proc } from "../../pty/pty" +import { Log } from "../../util/log" + +const log = Log.create({ service: "pty.termination" }) +const GRACE_MS = 200 +const SPAWN_TIMEOUT_MS = 5_000 + +export type Process = Pick + +export type Runtime = { + readonly platform: NodeJS.Platform + readonly taskkill: ( + file: string, + args: string[], + opts: { stdio: "ignore"; windowsHide: true; timeout: number }, + ) => Promise + readonly tree: () => Promise> + readonly alive: (pid: number) => boolean + readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void + readonly sleep: (ms: number) => Promise +} + +const runtime: Runtime = { + platform: process.platform, + taskkill, + tree, + alive: (pid) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }, + signal: (pid, signal) => process.kill(pid, signal), + sleep, +} + +function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") { + try { + proc.kill(signal) + } catch (err) { + log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal }) + } +} + +function descendants(root: number, rows: Array<{ pid: number; parent: number }>) { + const children = new Map() + for (const row of rows) { + const list = children.get(row.parent) ?? [] + list.push(row.pid) + children.set(row.parent, list) + } + const seen = new Set() + const collect = (pid: number): number[] => { + const result: number[] = [] + for (const child of children.get(pid) ?? []) { + if (seen.has(child)) continue + seen.add(child) + result.push(...collect(child), child) + } + return result + } + return collect(root) +} + +async function family(root: number, input: Runtime) { + const rows = await input.tree().catch((err) => { + log.debug("failed to inspect PTY process tree", { err, pid: root }) + return [] + }) + return [...descendants(root, rows), root] +} + +function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) { + for (const pid of pids) { + let sent = false + for (const target of [-pid, pid]) { + try { + input.signal(target, value) + sent = true + } catch (err) { + log.debug("failed to signal PTY process", { err, pid: target, signal: value }) + } + } + if (pid === proc.pid && !sent) direct(proc, value) + } +} + +async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) { + return await new Promise>((resolve) => { + try { + const child = spawn(file, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + timeout: SPAWN_TIMEOUT_MS, + killSignal: "SIGKILL", + }) + const chunks: Buffer[] = [] + child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk)) + child.once("error", () => resolve([])) + child.once("close", (code) => { + if (code !== 0) return resolve([]) + const rows = Buffer.concat(chunks) + .toString("utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => line.trim().split(/\s+/).map(Number)) + .filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent)) + .map(([pid, parent]) => ({ pid: pid!, parent: parent! })) + resolve(rows) + }) + } catch { + resolve([]) + } + }) +} + +async function taskkill( + file: string, + args: string[], + opts: { stdio: "ignore"; windowsHide: true; timeout: number }, +) { + return await new Promise((resolve) => { + try { + const child = spawn(file, args, opts) + child.once("exit", (code) => resolve(code === 0)) + child.once("error", (err) => { + log.warn("taskkill failed", { err }) + resolve(false) + }) + } catch (err) { + log.warn("failed to start taskkill", { err }) + resolve(false) + } + }) +} + +export async function terminate(proc: Process, input: Runtime = runtime): Promise { + const state = { exited: false } + const listener = proc.onExit(() => { + state.exited = true + }) + try { + if (!proc.pid) { + direct(proc) + if (!state.exited) await input.sleep(GRACE_MS) + return + } + + if (input.platform === "win32") { + const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { + stdio: "ignore", + windowsHide: true, + timeout: SPAWN_TIMEOUT_MS, + }) + if (!killed && !state.exited) direct(proc) + if (!state.exited) await input.sleep(GRACE_MS) + return + } + + const initial = await family(proc.pid, input) + signal(proc, initial, "SIGTERM", input) + await input.sleep(GRACE_MS) + const remaining = new Set(initial.filter(input.alive)) + if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid) + if (remaining.size > 0) { + signal(proc, [...remaining], "SIGKILL", input) + await input.sleep(GRACE_MS) + } + } finally { + listener.dispose() + } +} + +export * as KiloPtyTermination from "./termination" diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 8fff339ac1..a1b9be3044 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -210,13 +210,19 @@ export const layer = Layer.effect( if (snapshot) return snapshot if (Flag.KILO_DISABLE_MODELS_FETCH) return {} // Flock is cross-process: concurrent opencode CLIs can race on this cache file. - const text = yield* Effect.scoped( + return yield* Effect.scoped( Effect.gen(function* () { yield* Flock.effect(lockKey) - return yield* fetchAndWrite() + // kilocode_change start - re-read under the lock: a concurrent refresh + // may already have recovered the corrupted cache while we waited, and + // fetching again here would duplicate the network call. + const rechecked = yield* loadFromDisk + if (rechecked) return rechecked + // kilocode_change end + const text = yield* fetchAndWrite() + return JSON.parse(text) as Record }), ) - return JSON.parse(text) as Record }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 28e48f6b05..0157c0d8d1 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema" // kilocode_change import { Shell } from "./shell" import { lazy } from "./util/lazy" import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change +import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change const BUFFER_LIMIT = 1024 * 1024 * 2 // Exited sessions stay observable (status, exit code, retained output) until removed explicitly. @@ -35,6 +36,7 @@ type Active = { cursor: number subscribers: Map listeners: Disp[] + stopping: boolean // kilocode_change } export const Info = Schema.Struct({ @@ -83,6 +85,8 @@ export type AttachInput = { readonly onData: (chunk: string) => void // Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown. readonly onEnd: (event: { exitCode?: number }) => void + // Canonical routes can replay retained output after exit; legacy callers retain the former error. + readonly allowExited?: boolean // kilocode_change } export type Attachment = { @@ -147,23 +151,25 @@ export const layer = Layer.effect( session.subscribers.clear() } - function teardown(session: Active) { + // kilocode_change start - terminate the complete PTY tree before reporting removal. + async function teardown(session: Active) { + session.stopping = true + if (session.info.status === "running") await KiloPtyTermination.terminate(session.process) for (const listener of session.listeners) listener.dispose() session.listeners.length = 0 - if (session.info.status === "running") { - try { - session.process.kill() - } catch {} - } - notifyEnd(session, {}) + notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {}) } + // kilocode_change end - yield* Effect.addFinalizer(() => - Effect.sync(() => { - for (const session of sessions.values()) teardown(session) - sessions.clear() - exitOrder.length = 0 - }), + yield* Effect.addFinalizer( + () => + // kilocode_change start - wait for process-tree termination during async service teardown. + Effect.promise(async () => { + await Promise.all(Array.from(sessions.values()).map(teardown)) + sessions.clear() + exitOrder.length = 0 + }), + // kilocode_change end ) const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { @@ -173,14 +179,18 @@ export const layer = Layer.effect( }) const removeSession = Effect.fnUntraced(function* (id: PtyID) { - const session = sessions.get(id) - if (!session) return - sessions.delete(id) - const index = exitOrder.indexOf(id) - if (index !== -1) exitOrder.splice(index, 1) - yield* Effect.logInfo("removing session", { id }) - teardown(session) - yield* events.publish(Event.Deleted, { id: session.info.id }) + // kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition. + yield* Effect.gen(function* () { + const session = sessions.get(id) + if (!session) return + yield* Effect.logInfo("removing session", { id }) + yield* Effect.promise(() => teardown(session)) + sessions.delete(id) + const index = exitOrder.indexOf(id) + if (index !== -1) exitOrder.splice(index, 1) + yield* events.publish(Event.Deleted, { id: session.info.id }) + }).pipe(Effect.uninterruptible) + // kilocode_change end }) const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { @@ -204,9 +214,10 @@ export const layer = Layer.effect( args: input.args ? [...input.args] : undefined, cwd: input.cwd, }) + const implicit = !resolved.command const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell")) const base = resolved.args ?? [] - const args = Shell.login(command) ? [...base, "-l"] : [...base] + const args = implicit && Shell.login(command) ? [...base, "-l"] : [...base] const cwd = resolved.cwd || location.directory // kilocode_change end const env = { @@ -246,6 +257,7 @@ export const layer = Layer.effect( cursor: 0, subscribers: new Map(), listeners: [], + stopping: false, // kilocode_change } sessions.set(id, session) session.listeners.push( @@ -269,7 +281,7 @@ export const layer = Layer.effect( session.bufferCursor += excess }), proc.onExit(({ exitCode }) => { - if (session.info.status === "exited") return + if (session.info.status === "exited" || session.stopping) return // kilocode_change session.info.status = "exited" session.info.exitCode = exitCode notifyEnd(session, { exitCode }) @@ -309,7 +321,7 @@ export const layer = Layer.effect( const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) { const session = yield* requireSession(id) - if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id }) + if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change yield* Effect.logInfo("client attached to session", { id, directory: location.directory }) const token = {} const subscriber: Subscriber = { @@ -318,6 +330,7 @@ export const layer = Layer.effect( active: false, detached: false, pending: [], + end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change } session.subscribers.set(token, subscriber) const start = session.bufferCursor diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 12d4c0eb0e..e154519560 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -165,11 +165,13 @@ export const layer = Layer.effect( ) const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program return abortable.pipe( - Effect.mapError((cause) => - cause instanceof Error || cause instanceof InvalidPatternError - ? cause - : failure("ripgrep execution failed", cause), - ), + // kilocode_change start - surface the underlying reason instead of a bare wrapper message + Effect.mapError((cause) => { + if (cause instanceof Error || cause instanceof InvalidPatternError) return cause + const detail = cause instanceof globalThis.Error && cause.message.trim() ? `: ${cause.message.trim()}` : "" + return failure(`ripgrep execution failed${detail}`, cause) + }), + // kilocode_change end ) } diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 233f623837..77f5d55160 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -229,6 +229,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermissionV1.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + web_search: Schema.optional(Schema.Boolean).annotate({ + description: "Make web search available to models from all providers (default: false)", + }), // kilocode_change attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", }), diff --git a/packages/core/src/v1/permission.ts b/packages/core/src/v1/permission.ts index b241ccd907..8c410562e6 100644 --- a/packages/core/src/v1/permission.ts +++ b/packages/core/src/v1/permission.ts @@ -45,6 +45,8 @@ export type Reply = typeof Reply.Type export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.String.pipe(Schema.optional), + // kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches + interactive: Schema.Boolean.pipe(Schema.optional), }).annotate({ identifier: "PermissionReplyBody" }) export type ReplyBody = typeof ReplyBody.Type diff --git a/packages/core/test/kilocode/exit-code.test.ts b/packages/core/test/kilocode/exit-code.test.ts new file mode 100644 index 0000000000..a316e839b3 --- /dev/null +++ b/packages/core/test/kilocode/exit-code.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { settle } from "@opencode-ai/core/kilocode/exit-code" +import { testEffect } from "../lib/effect" + +const fx = testEffect(CrossSpawnSpawner.defaultLayer) + +const code = (exit: readonly [number | null, NodeJS.Signals | null]) => Effect.runPromise(settle(exit)) + +describe("exit-code settle", () => { + test("maps exit results to numeric codes", async () => { + expect(await code([0, null])).toBe(ChildProcessSpawner.ExitCode(0)) + expect(await code([42, null])).toBe(ChildProcessSpawner.ExitCode(42)) + expect(await code([null, "SIGSEGV"])).toBe(ChildProcessSpawner.ExitCode(128 + 11)) + expect(await code([null, "SIGTERM"])).toBe(ChildProcessSpawner.ExitCode(128 + 15)) + expect(await code([null, null])).toBe(ChildProcessSpawner.ExitCode(1)) + expect(await code([null, "SIGWHAT" as NodeJS.Signals])).toBe(ChildProcessSpawner.ExitCode(1)) + }) + + fx.effect( + "reports signal termination as 128 + signum", + Effect.gen(function* () { + if (process.platform === "win32") return + const handle = yield* ChildProcess.make(process.execPath, ["-e", "process.kill(process.pid, 'SIGKILL')"]) + expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(128 + 9)) + }), + ) +}) diff --git a/packages/core/test/kilocode/pty-termination.test.ts b/packages/core/test/kilocode/pty-termination.test.ts new file mode 100644 index 0000000000..a6162cfbe0 --- /dev/null +++ b/packages/core/test/kilocode/pty-termination.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import { KiloPtyTermination } from "../../src/kilocode/pty/termination" + +function fake(pid = 123) { + const calls: Array = [] + const proc: KiloPtyTermination.Process = { + pid, + onExit: () => ({ dispose() {} }), + kill: (signal) => calls.push(signal), + } + return { proc, calls } +} + +function runtime( + platform: NodeJS.Platform, + input: { + taskkill?: boolean + signal?: "throw" + tree?: Array<{ pid: number; parent: number }> + } = {}, +) { + const tasks: Array<{ + file: string + args: string[] + opts: { stdio: "ignore"; windowsHide: true; timeout: number } + }> = [] + const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = [] + const sleeps: number[] = [] + const value: KiloPtyTermination.Runtime = { + platform, + taskkill: async (file, args, opts) => { + tasks.push({ file, args, opts }) + return input.taskkill ?? true + }, + tree: async () => input.tree ?? [], + alive: () => true, + signal: (pid, signal) => { + signals.push({ pid, signal }) + if (input.signal === "throw") throw new Error("process group unavailable") + }, + sleep: async (ms) => { + sleeps.push(ms) + }, + } + return { value, tasks, signals, sleeps } +} + +describe("pty process-tree termination", () => { + test("uses hidden taskkill for Windows process trees", async () => { + const item = fake(42) + const input = runtime("win32") + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.tasks).toEqual([ + { + file: "taskkill", + args: ["/pid", "42", "/f", "/t"], + opts: { stdio: "ignore", windowsHide: true, timeout: 5_000 }, + }, + ]) + expect(input.signals).toEqual([]) + expect(item.calls).toEqual([]) + expect(input.sleeps).toEqual([200]) + }) + + test("signals POSIX process groups before escalating", async () => { + const item = fake(42) + const input = runtime("linux") + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.signals).toEqual([ + { pid: -42, signal: "SIGTERM" }, + { pid: 42, signal: "SIGTERM" }, + { pid: -42, signal: "SIGKILL" }, + { pid: 42, signal: "SIGKILL" }, + ]) + expect(item.calls).toEqual([]) + expect(input.sleeps).toEqual([200, 200]) + }) + + test("falls back to direct PTY signals when a process group is unavailable", async () => { + const item = fake(42) + const input = runtime("darwin", { signal: "throw" }) + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(item.calls).toEqual(["SIGTERM", "SIGKILL"]) + }) + + test("signals descendants that run in separate process groups", async () => { + const item = fake(42) + const input = runtime("linux", { + tree: [ + { pid: 43, parent: 42 }, + { pid: 44, parent: 43 }, + ], + }) + + await KiloPtyTermination.terminate(item.proc, input.value) + + expect(input.signals).toContainEqual({ pid: -44, signal: "SIGTERM" }) + expect(input.signals).toContainEqual({ pid: 44, signal: "SIGKILL" }) + expect(input.signals).toContainEqual({ pid: -43, signal: "SIGTERM" }) + expect(input.signals).toContainEqual({ pid: 43, signal: "SIGKILL" }) + }) +}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 6b78ec4d13..30e223faac 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -127,6 +127,53 @@ describe("pty", () => { }), ) + // kilocode_change start - explicit commands must not acquire implicit login-shell arguments. + ptyTest("preserves explicit command arguments", () => + Effect.gen(function* () { + const args = ["-c", 'printf "<%s>" "$0"; sleep 5'] + const info = yield* createPty("sh", args) + expect(info.args).toEqual(args) + + const attached = yield* attachCollecting(info.id) + expect(yield* waitForOutput(attached.output, "")).toContain("") + }), + ) + + // (script terminals forward raw output to xterm without transcoding). + ptyTest("round-trips non-ASCII output byte-identically", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const marker = "café-über-北京-🚀" + const info = yield* createPty("sh", ["-c", `printf '${marker}\\n'`]) + const attached = yield* attachCollecting(info.id) + expect(yield* waitForOutput(attached.output, marker)).toContain(marker) + }), + ) + ptyTest("terminates background descendants outside the shell process group", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("sh", ["-c", 'sleep 30 & printf "" "$!"; wait']) + const attached = yield* attachCollecting(info.id) + const output = yield* waitForOutput(attached.output, ">") + const match = output.match(//) + expect(match?.[1]).toBeDefined() + const pid = Number(match?.[1]) + + yield* pty.remove(info.id) + yield* Effect.sleep("100 millis") + const alive = yield* Effect.sync(() => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }) + expect(alive).toBe(false) + }), + ) + // kilocode_change end + ptyTest("replays buffered output and streams live output to attachments", () => Effect.gen(function* () { const pty = yield* Pty.Service @@ -201,6 +248,31 @@ describe("pty", () => { expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id }) }), ) + + // kilocode_change start - canonical attachments replay retained exited output, then end without accepting input. + ptyTest("replays exited output and ends when enabled", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty("sh", ["-c", 'printf "replayed"; exit 7']) + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"]) + + const ended = yield* Deferred.make<{ exitCode?: number }>() + const attachment = yield* pty.attach(info.id, { + allowExited: true, + onData: () => {}, + onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)), + }) + expect(attachment.replay).toContain("replayed") + + attachment.write("ignored") + yield* pty.remove(info.id) + attachment.activate() + expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 }) + attachment.detach() + }), + ) + // kilocode_change end }) const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 677ecc2dd6..b8e19e39ea 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -61,4 +61,18 @@ describe("Ripgrep", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) + + // kilocode_change start - surfaced error keeps the underlying reason + it.live("includes the underlying reason in execution failures", () => + Effect.gen(function* () { + const ripgrep = yield* Ripgrep.Service + const controller = new AbortController() + controller.abort() + const error = yield* ripgrep + .find({ cwd: process.cwd(), pattern: "*", limit: 1, signal: controller.signal }) + .pipe(Effect.flip) + expect(error.message).toMatch(/^ripgrep execution failed: .+/) + }), + ) + // kilocode_change end }) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 920418a989..bdc5267013 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 66a2274823..c5863bfad0 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d31c75a3d8..a912fd5940 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.16" +version = "7.4.17" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index fec3e374d0..46de980c7a 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.16", + "version": "7.4.17", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 82ed878f47..53ac288500 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.16", + "version": "7.4.17", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-console/src/routes/config/SourcesRoute.tsx b/packages/kilo-console/src/routes/config/SourcesRoute.tsx index 3fe405b8f0..d6fb5eb0e6 100644 --- a/packages/kilo-console/src/routes/config/SourcesRoute.tsx +++ b/packages/kilo-console/src/routes/config/SourcesRoute.tsx @@ -13,7 +13,11 @@ export function SourcesRoute() { {data().overlay.targets.active ?? "Read only"}} + meta={ + + {data().overlay.targets.active.writable ? data().overlay.targets.active.scope : "Read only"} + + } />
diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 1aca8a16b8..1dd1001e66 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -1,14 +1,18 @@ import { createMemo, createSignal, For, Show } from "solid-js" import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console" +import { Button } from "@kilocode/kilo-web-ui/button" +import { Card } from "@kilocode/kilo-web-ui/card" import { SearchField } from "../../components/SearchField" import { useConfig } from "../../context/config" import { toolCapabilities, toolName } from "../../shared/utils" -import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" export function ToolsRoute() { const ctx = useConfig() const [search, setSearch] = createSignal("") const snap = () => ctx.data() + const websearch = createMemo(() => snap()?.overlay.fields.web_search) + const searchEnabled = createMemo(() => websearch()?.value === true) const rows = createMemo(() => { const data = snap() if (!data) return [] @@ -52,6 +56,48 @@ export function ToolsRoute() { } description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools." > + +
+
+

Web search

+

Control web search availability for models from providers that do not enable it by default.

+
+ + + +
+
+ +
+
+ ("generateProviderIcons") { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 9b1e517ac6..d032d281cc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -722,7 +722,10 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" } cs.launch { try { - if (!autoApprove) { + // Skill-shell batches must be answered by a human: the server refuses + // non-interactive approvals, so auto-approve must show the card (whose + // manual reply sets interactive=true) rather than send a machine reply. + if (!autoApprove || restore().meta.raw["skillShell"] == "true") { edt { if (disposed) return@edt model.setState(SessionState.AwaitingPermission(restore())) @@ -759,9 +762,16 @@ class SessionController( try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip } val count = replyAll(permissions) - if (count == 0) return@launch + // Skill-shell requests are skipped by replyAll; surface one as a card so it + // isn't stranded (never machine-approved, never shown). + val card = skillShellCard(permissions)?.let { toPermission(it) } + if (count == 0 && card == null) return@launch runEdt { if (disposed) return@runEdt + if (card != null) { + updateModel { model.setState(SessionState.AwaitingPermission(card)) } + return@runEdt + } val current = model.state if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) @@ -777,6 +787,8 @@ class SessionController( var count = 0 for (request in permissions) { if (!autoApprove) return count + // Skill-shell batches need a human; skip them here (callers surface the card). + if (request.metadata["skillShell"] == "true") continue sessions.replyPermission(request.id, directory, PermissionReplyDto("once")) capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain")) count++ @@ -784,6 +796,11 @@ class SessionController( return count } + // A skill-shell request is never machine-approved (the server refuses non-interactive + // approvals); after draining, callers must surface one as a card so a human can answer. + private fun skillShellCard(permissions: List): PermissionRequestDto? = + permissions.lastOrNull { it.metadata["skillShell"] == "true" } + private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { assertEdt() val current = model.state @@ -1156,11 +1173,15 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } if (permissions.isEmpty()) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" } - if (autoApprove) { + // A skill-shell request must surface as a card even under auto-approve (replyAll + // skips it); prefer it over the last pending so a human can answer. + val show = if (autoApprove) { replyAll(permissions) - return + skillShellCard(permissions) ?: return + } else { + skillShellCard(permissions) ?: permissions.last() } - val last = toPermission(permissions.last()) + val last = toPermission(show) runEdt { if (disposed) return@runEdt if (child !in childIds) return@runEdt @@ -1201,9 +1222,12 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } val status = sessions.statuses.value[id] + // replyAll auto-approves the ordinary permissions and skips skill-shell ones. A + // skill-shell request must then fall through to a human card rather than go Busy. + val skillCard = skillShellCard(permissions) if (permissions.isNotEmpty() && autoApprove) { val count = replyAll(permissions) - if (count > 0) { + if (count > 0 && skillCard == null) { runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1226,7 +1250,8 @@ class SessionController( if (sid != id) return@runEdt updateModel { if (permissions.isNotEmpty()) { - model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + // Prefer a skill-shell request (needs a human) over the last pending. + model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last()))) } else if (questions.isNotEmpty()) { model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) } else if (status != null) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d5323dd58a..0ae649ef54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -356,7 +356,7 @@ class PermissionView( card.setActionEnabled(ID_RUN, false) card.setActionEnabled(ID_DENY, false) rules.setControlsEnabled(false) - reply(id, PermissionReplyDto(reply = "once"), rulePayload()) + reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload()) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 9d5e4225e1..768c9e36b3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() { ) } + fun `test auto approve does not machine-reply a skill shell batch`() { + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + // skill-shell batches must be answered by a human; auto-approve must show the card + // instead of sending a non-interactive reply the server would refuse. + emit( + ChatEventDto.PermissionAsked( + "ses_test", + permission("perm1").copy(metadata = mapOf("skillShell" to "true")), + ), + ) + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test disabling auto approve before reply restores awaiting permission`() { val (m, _, _) = prompted() @@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("once", rpc.permissionReplies[0].third.reply) } + fun `test enabling auto approve surfaces a pending skill shell as a card`() { + val (m, _, _) = prompted() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + + edt { m.setAutoApprove(true) } + flush() + + // skill-shell must not be machine-approved; it surfaces as a human card instead + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + fun `test recovery surfaces a pending skill shell as a card under auto approve`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + edt { KiloPluginSettings.setAutoApprove(true) } + + val m = controller("ses_test") + flush() + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test auto approve drains pending permissions during recovery`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady() diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 15b48416fe..182e9b77c4 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -5,6 +5,9 @@ intellij-rpc-plugin = "2.3.20-RC2-0.1" kotlin-jvm-plugin = "2.3.20" kotlin-serialization-plugin = "2.3.20" kotlin-serialization = "1.11.0" +kotlinx-coroutines = "1.10.2" +junit = "4.13.2" +junit-vintage = "5.11.4" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" @@ -22,7 +25,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" } diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index fee84e7b2a..57a8e11ef2 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.16", + "version": "7.4.17", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index edbbed7b02..f4e222bbb2 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -328,6 +328,8 @@ data class ToolRefDto( data class PermissionReplyDto( val reply: String, val message: String? = null, + // Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches. + val interactive: Boolean = false, ) @Serializable diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index df2adc3325..d48bcabe87 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-memory/src/capture/parse.ts b/packages/kilo-memory/src/capture/parse.ts index 21f756bef3..eb71e6269a 100644 --- a/packages/kilo-memory/src/capture/parse.ts +++ b/packages/kilo-memory/src/capture/parse.ts @@ -72,12 +72,10 @@ export const typedSchema = z }) .strict() -export const digestSchema = z - .object({ - topic: z.string().max(160).default(""), - summary: z.string().max(64_000).default(""), - }) - .strict() +export const digestSchema = z.object({ + topic: z.string().max(160).default(""), + summary: z.string().max(64_000).default(""), +}) export type CaptureSkip = z.infer["skipped"][number] export type CaptureDigest = z.infer diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index 01d81da699..eba37bc79d 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -29,6 +29,20 @@ describe("memory capture parsing", () => { expect(parsed).toEqual({ topic: "repo setup", summary: "Run package tests." }) }) + test("ignores additional digest fields from model output", () => { + const parsed = parseJson( + digestSchema, + JSON.stringify({ + topic: "repo setup", + summary: "Run package tests.", + next_step: "Open a pull request.", + blockers: [], + }), + ) + + expect(parsed).toEqual({ topic: "repo setup", summary: "Run package tests." }) + }) + test("maps consolidation operation names into deterministic memory operations", () => { const parsed = parseJson( typedSchema, diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index ff29edd756..b57728afed 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 7b9e81497a..36243e82c9 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/__tests__/identity.test.ts b/packages/kilo-telemetry/src/__tests__/identity.test.ts new file mode 100644 index 0000000000..3754c047f0 --- /dev/null +++ b/packages/kilo-telemetry/src/__tests__/identity.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, readFileSync, existsSync, statSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test" +import { createHash } from "node:crypto" + +let profileCalls = 0 +mock.module("@kilocode/kilo-gateway", () => ({ + fetchProfile: async (token: string) => { + profileCalls++ + if (token === "bad-token") return null + return { email: `user-${token}@example.com` } + }, +})) + +const { Identity } = await import("../identity.js") + +function digest(token: string) { + return createHash("sha256").update(token).digest("hex") +} + +let dir: string + +beforeEach(() => { + profileCalls = 0 + dir = mkdtempSync(path.join(tmpdir(), "kilo-telemetry-identity-")) + Identity.reset() + Identity.setDataPath(dir) +}) + +afterEach(() => { + Identity.setDataPath("") +}) + +describe("Identity.updateFromKiloAuth profile cache", () => { + test("fetches profile and writes cache keyed by token hash", async () => { + await Identity.updateFromKiloAuth("token-a") + expect(Identity.getUserId()).toBe("user-token-a@example.com") + expect(profileCalls).toBe(1) + + const file = path.join(dir, "telemetry-profile.json") + expect(existsSync(file)).toBe(true) + const cache = JSON.parse(readFileSync(file, "utf8")) + expect(cache.token).toBe(digest("token-a")) + expect(cache.email).toBe("user-token-a@example.com") + expect(cache.token).not.toBe("token-a") + // The cache stores an email and a token verifier, so it must be owner-only. + // POSIX only: Windows reports default mode bits and enforces access via ACLs. + if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600) + }) + + test("uses cached email without a network request on later invocations", async () => { + await Identity.updateFromKiloAuth("token-a") + expect(profileCalls).toBe(1) + + // Simulate a fresh process: identity state resets, cache file persists. + Identity.reset() + await Identity.updateFromKiloAuth("token-a") + expect(Identity.getUserId()).toBe("user-token-a@example.com") + expect(profileCalls).toBe(1) + }) + + test("refetches when the token changes", async () => { + await Identity.updateFromKiloAuth("token-a") + Identity.reset() + await Identity.updateFromKiloAuth("token-b") + expect(Identity.getUserId()).toBe("user-token-b@example.com") + expect(profileCalls).toBe(2) + }) + + test("clears identity when token is null", async () => { + await Identity.updateFromKiloAuth("token-a") + Identity.reset() + await Identity.updateFromKiloAuth(null) + expect(Identity.getUserId()).toBeNull() + expect(profileCalls).toBe(1) + }) + + test("ignores a cache file for a different token", async () => { + await Identity.updateFromKiloAuth("token-a") + const file = path.join(dir, "telemetry-profile.json") + const cache = JSON.parse(readFileSync(file, "utf8")) + expect(cache.token).toBe(digest("token-a")) + + Identity.reset() + await Identity.updateFromKiloAuth("token-b") + expect(Identity.getUserId()).toBe("user-token-b@example.com") + }) +}) diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index ff87bf6909..939bb5dd24 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -1,4 +1,6 @@ -import { describe, test, expect, beforeEach } from "bun:test" +import { arch, platform, release } from "node:os" +import { describe, test, expect, beforeEach, spyOn } from "bun:test" +import { Client } from "../client.js" import { Identity } from "../identity.js" import { TelemetryEvent } from "../events.js" import { Telemetry } from "../telemetry.js" @@ -83,6 +85,22 @@ describe("TelemetryEvent", () => { }) describe("Telemetry", () => { + test("includes host OS properties", () => { + const capture = spyOn(Client, "capture").mockImplementation(() => {}) + + Telemetry.track(TelemetryEvent.CLI_START) + + expect(capture).toHaveBeenCalledWith( + TelemetryEvent.CLI_START, + expect.objectContaining({ + os_name: platform(), + os_version: release(), + os_arch: arch(), + }), + ) + capture.mockRestore() + }) + test("indexing helpers are exposed", () => { expect(typeof Telemetry.trackIndexingStarted).toBe("function") expect(typeof Telemetry.trackIndexingCompleted).toBe("function") @@ -96,4 +114,3 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) }) - diff --git a/packages/kilo-telemetry/src/client.ts b/packages/kilo-telemetry/src/client.ts index 24c902b543..f5baa1ec54 100644 --- a/packages/kilo-telemetry/src/client.ts +++ b/packages/kilo-telemetry/src/client.ts @@ -82,4 +82,25 @@ export namespace Client { } } } + + // Flush queued events in the background without blocking the caller. The + // flush is delayed slightly so commands that exit immediately pay only the + // single shutdown() flush instead of an in-flight flush plus a follow-up + // flush for CLI_EXIT. For commands that outlive the delay, the upload + // overlaps with execution, so by the time shutdown() runs the queue is + // usually empty (or the connection is still warm) and process exit is not + // delayed by a network round trip. The unref'd timer never keeps a process + // alive on its own. The authoritative, error-handled flush still happens in + // shutdown(); failures here are retried there, so they are only surfaced + // when debug logging is on. + export function flushInBackground(delayMs = 300): void { + if (!enabled || !client) return + const timer = setTimeout(() => { + if (!client) return + client.flush().catch((err) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err) + }) + }, delayMs) + timer.unref?.() + } } diff --git a/packages/kilo-telemetry/src/identity.ts b/packages/kilo-telemetry/src/identity.ts index c75fac4d29..974a32120d 100644 --- a/packages/kilo-telemetry/src/identity.ts +++ b/packages/kilo-telemetry/src/identity.ts @@ -1,4 +1,6 @@ import * as path from "path" +import { createHash } from "crypto" +import { writeFile, chmod, rename, rm } from "fs/promises" import { fetchProfile } from "@kilocode/kilo-gateway" export namespace Identity { @@ -7,6 +9,21 @@ export namespace Identity { let organizationId: string | null = null let dataPath = "" + // Cache the email resolved from the auth token so CLI startup does not block on + // a profile request for every invocation. Keyed by token hash; refreshed when + // the token changes. Stale entries (older than a week) are still used for the + // current run and refreshed on a best-effort basis for a later run: the + // background refresh is not awaited, so short-lived invocations may exit before + // it completes and simply retry next time. + const CACHE_FILE = "telemetry-profile.json" + const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 + + interface Cache { + token: string + email: string + fetchedAt: number + } + export function setDataPath(p: string) { dataPath = p } @@ -51,6 +68,45 @@ export namespace Identity { organizationId = orgId } + function digest(token: string): string { + return createHash("sha256").update(token).digest("hex") + } + + async function read(): Promise { + if (!dataPath) return null + const file = Bun.file(path.join(dataPath, CACHE_FILE)) + if (!(await file.exists())) return null + const parsed = await file.json().catch(() => null) + if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null + if (typeof parsed.fetchedAt !== "number") return null + return parsed as Cache + } + + async function write(cache: Cache): Promise { + if (!dataPath) return + const filepath = path.join(dataPath, CACHE_FILE) + // The cache stores the user's email and a token verifier, so keep it + // readable only by the owner, including when replacing an existing file. + // Write to a temp file and rename so concurrent invocations or a mid-write + // kill cannot leave a truncated cache behind (POSIX rename is atomic). + const tmp = `${filepath}.${process.pid}.tmp` + try { + await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 }) + await chmod(tmp, 0o600) + await rename(tmp, filepath) + } catch (err) { + await rm(tmp, { force: true }).catch((rmErr) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr) + }) + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err) + } + } + + async function refresh(token: string, tokenHash: string): Promise { + const profile = await fetchProfile(token).catch(() => null) + if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() }) + } + export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise { organizationId = accountId || null @@ -59,8 +115,21 @@ export namespace Identity { return } + const tokenHash = digest(token) + const cached = await read() + if (cached && cached.token === tokenHash) { + userId = cached.email + if (Date.now() - cached.fetchedAt > CACHE_TTL) { + refresh(token, tokenHash).catch((err) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err) + }) + } + return + } + const profile = await fetchProfile(token).catch(() => null) userId = profile?.email || null + if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() }) } export function reset() { diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 3ac0d42e39..496bf1ad4c 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -1,3 +1,4 @@ +import { release } from "node:os" import { Client } from "./client.js" import { Identity } from "./identity.js" import { TelemetryEvent } from "./events.js" @@ -6,6 +7,9 @@ export interface TelemetryProperties { appName: string appVersion: string platform: string + os_name: string + os_version: string + os_arch: string editorName?: string vscodeVersion?: string } @@ -58,6 +62,9 @@ export namespace Telemetry { appName: "kilo-cli", appVersion: "unknown", platform: process.platform, + os_name: process.platform, + os_version: release(), + os_arch: process.arch, } export async function init(options: { dataPath: string; version: string; enabled: boolean }): Promise { @@ -109,6 +116,9 @@ export namespace Telemetry { appName: props.appName, appVersion: props.appVersion, platform: props.platform, + os_name: props.os_name, + os_version: props.os_version, + os_arch: props.os_arch, }) // Link the anonymous machineId to the authenticated email @@ -125,6 +135,12 @@ export namespace Telemetry { track(TelemetryEvent.CLI_START) } + // Upload queued events without blocking. Call after bootstrap so the flush + // overlaps with command execution and shutdown() stays fast (#10242). + export function flushInBackground() { + Client.flushInBackground() + } + export function trackCliExit(exitCode?: number) { track(TelemetryEvent.CLI_EXIT, { duration: Date.now() - startTime, diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 7ee9692c7e..a4e4d07a16 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.16", + "version": "7.4.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index d41e4a8c94..8e0e1d920c 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,78 @@ # kilo-code +## 7.4.17 + +### Minor Changes + +- [#12631](https://github.com/Kilo-Org/kilocode/pull/12631) [`3321216`](https://github.com/Kilo-Org/kilocode/commit/3321216c0157e1a8a1829b0c8e0a1cae8d2f2ad2) - Show the ⌘1-9 (Ctrl+1-9 on Windows/Linux) shortcut badges on every Agent Manager sidebar card while the modifier key is held, making it easy to see which number jumps to which worktree before pressing it. + +- [#12598](https://github.com/Kilo-Org/kilocode/pull/12598) [`c6711fc`](https://github.com/Kilo-Org/kilocode/commit/c6711fcf6cea9fdbe78a04b95276d09a2faabfa7) - Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden. + +- [#12633](https://github.com/Kilo-Org/kilocode/pull/12633) [`23039c0`](https://github.com/Kilo-Org/kilocode/commit/23039c0fb1e5b32704119ddde10a6a28ccd6bff3) - Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab. + +- [#12632](https://github.com/Kilo-Org/kilocode/pull/12632) [`0d853df`](https://github.com/Kilo-Org/kilocode/commit/0d853df3ec338ac99e025939f74136dec6d9daa1) - Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat. + +### Patch Changes + +- [#12629](https://github.com/Kilo-Org/kilocode/pull/12629) [`0a1c140`](https://github.com/Kilo-Org/kilocode/commit/0a1c14073a4bf14f8ad4e3c8295dc6ae6bfbfdaf) - Keep each Agent Manager panel's terminal destination consistent. A dropdown pick is now remembered per panel and no longer flips when another window rewrites the shared terminal destination setting, so the terminal shortcut keeps opening the terminal type that panel is actually using. The shortcut also no longer dead-ends on worktrees without an active session, and terminals left over from a reloaded webview are cleaned up instead of leaking. + +- [#12587](https://github.com/Kilo-Org/kilocode/pull/12587) [`16f8e7e`](https://github.com/Kilo-Org/kilocode/commit/16f8e7ef7fbd47755395539e7df54af3baae0c63) - Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. + +- [#12333](https://github.com/Kilo-Org/kilocode/pull/12333) [`290a5af`](https://github.com/Kilo-Org/kilocode/commit/290a5af56e6ddccd8b4a459883625e30f2ae0344) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console. + +- [#12630](https://github.com/Kilo-Org/kilocode/pull/12630) [`a7f972f`](https://github.com/Kilo-Org/kilocode/commit/a7f972f63bc948d70b73a36a5beab6d694316037) - Open Agent Manager terminals faster and avoid delayed shell prompts. + +- [#12560](https://github.com/Kilo-Org/kilocode/pull/12560) [`65c5e9d`](https://github.com/Kilo-Org/kilocode/commit/65c5e9d2c03cea152b140710228075edf9156def) - Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. + +- [#12561](https://github.com/Kilo-Org/kilocode/pull/12561) [`44f5963`](https://github.com/Kilo-Org/kilocode/commit/44f596366931d5336f1cd4dfdd97ef54e0f2fa4c) - Fix settings changes sometimes failing to save and apply in VS Code. + +- [#12540](https://github.com/Kilo-Org/kilocode/pull/12540) [`2da8949`](https://github.com/Kilo-Org/kilocode/commit/2da89498138e49f857c354924fdecac85337e742) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Preserve parenthesized tilde expressions as literal text in rendered chat messages. + +- [#12591](https://github.com/Kilo-Org/kilocode/pull/12591) [`625d2b9`](https://github.com/Kilo-Org/kilocode/commit/625d2b974de1381b4d475808c9215becb263d1f0) - Show prompt input toggle tooltips instantly on hover instead of after a delay. + +- [#12460](https://github.com/Kilo-Org/kilocode/pull/12460) [`51d8031`](https://github.com/Kilo-Org/kilocode/commit/51d8031c9997bd5478bcde715562169f732d04d4) - Changes from opencode v1.17.5 to v1.17.9 upstream: + - Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. + - Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. + - Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. + - Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. + - Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) + - Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) + - Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. + - Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) + - Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. + - Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. + - Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. + - Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. + - Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. + - Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. + - Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) + - Core Bugfixes: Pass configured custom headers to Copilot model requests. + - Core Improvements: MCP servers can now receive the current workspace as a client root. + - Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. + - Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) + - Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. + - TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. + - TUI Bugfixes: Only show the background subagent shortcut when the server supports it. + - UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. + +- [#11984](https://github.com/Kilo-Org/kilocode/pull/11984) [`26dac19`](https://github.com/Kilo-Org/kilocode/commit/26dac197fe28294c391c8d437abf06e18e2d22bd) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Mention `@` file references in the chat input placeholder so users know they can add file mentions. Translated across all supported languages. + +- [#12593](https://github.com/Kilo-Org/kilocode/pull/12593) [`160b066`](https://github.com/Kilo-Org/kilocode/commit/160b06661acc5f04b21221ab6578c468325f64c5) - Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. + +- [#12123](https://github.com/Kilo-Org/kilocode/pull/12123) [`3075d35`](https://github.com/Kilo-Org/kilocode/commit/3075d35f13ba9738446ac28fa2eebf054097f2f5) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Keep message and response copy buttons working after switching focus away from VS Code. + +- [#12583](https://github.com/Kilo-Org/kilocode/pull/12583) [`1310c12`](https://github.com/Kilo-Org/kilocode/commit/1310c1200ab613b316f27fe4fd59e23e262df02f) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed. + +- [#12410](https://github.com/Kilo-Org/kilocode/pull/12410) [`85d65a3`](https://github.com/Kilo-Org/kilocode/commit/85d65a3137ecadfcbda8255bfb4a40daf1f155fb) - Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes. + +- [#12639](https://github.com/Kilo-Org/kilocode/pull/12639) [`8a47d8b`](https://github.com/Kilo-Org/kilocode/commit/8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c) - Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. + +- [#12602](https://github.com/Kilo-Org/kilocode/pull/12602) [`5d87ca5`](https://github.com/Kilo-Org/kilocode/commit/5d87ca598c4c66328f0e476cb1be3fc9b26d05aa) - Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys. + +- [#12463](https://github.com/Kilo-Org/kilocode/pull/12463) [`1f3383c`](https://github.com/Kilo-Org/kilocode/commit/1f3383cf3de37327b02e0fc2a1c5ac176ca9134f) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints + +- [#12634](https://github.com/Kilo-Org/kilocode/pull/12634) [`4c5c242`](https://github.com/Kilo-Org/kilocode/commit/4c5c2428927f26c4c818f23a650cfaf5723b7641) - Show the worktree directory name on the Agent Manager worktree hover card + ## 7.4.16 ### Minor Changes diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index d19df68b45..8aedaac465 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -43,17 +43,10 @@ export default [ }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], - // Raised from 3100 → 3200 for the experimental terminal tabs feature. - // ~600 lines of terminal logic were extracted to ./terminal/* and - // ./tab-rendering.tsx; the remaining ~75 lines are signal bindings, - // a stacking-container wrapper required by the hydration invariant - // (canvases must never leave the paint tree — see render.tsx), and - // render-call wiring that must live at the top of - // `AgentManagerContent` alongside the existing selection/session state. - // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider` - // wiring, which sits inside the provider chain and cannot be extracted - // without adding an intermediate wrapper component. - rules: { complexity: ["error", 74], "max-lines": ["error", 3210] }, + // 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. + rules: { complexity: ["error", 74], "max-lines": ["error", 2800] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 45d6fbeb52..75e0fbb56c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.16", + "version": "7.4.17", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -678,7 +678,7 @@ "command": "kilo-code.new.agentManager.showTerminal", "key": "ctrl+/", "mac": "cmd+/", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused" }, { "command": "kilo-code.new.agentManager.runScript", @@ -868,7 +868,8 @@ "tr", "nl", "uk", - "it" + "it", + "fa" ], "enumDescriptions": [ "Auto (VS Code language)", @@ -891,7 +892,8 @@ "Türkçe", "Nederlands", "Українська", - "Italiano" + "Italiano", + "فارسی" ] }, "kilo-code.new.languageCommitMessage": { @@ -919,7 +921,8 @@ "tr", "nl", "uk", - "it" + "it", + "fa" ], "enumDescriptions": [ "跟随界面语言 (Sync with UI language)", @@ -942,7 +945,8 @@ "Türkçe", "Nederlands", "Українська", - "Italiano" + "Italiano", + "فارسی" ] }, "kilo-code.new.model.providerID": { @@ -1016,6 +1020,12 @@ "scope": "application", "description": "Prefix for automatically named Agent Manager branches, for example 'marius/' or 'feature/'. Explicit branch names are unchanged." }, + "kilo-code.new.experimental.multiProject": { + "type": "boolean", + "default": false, + "scope": "application", + "description": "Allow Agent Manager to manage sessions and worktrees across multiple repositories as projects. The current workspace repository is always the default project; additional projects can be added and persist across restarts." + }, "kilo-code.new.agentManager.terminalButtonDestination": { "type": "string", "scope": "application", @@ -1028,7 +1038,7 @@ "Open or focus the VS Code integrated terminal.", "Open or focus an embedded terminal in the Agent Manager side panel." ], - "description": "Choose where the Agent Manager terminal button and Focus Terminal keyboard shortcut open a terminal." + "description": "Default destination for the Agent Manager terminal button and Focus Terminal keyboard shortcut. The terminal button's dropdown remembers its own choice per Agent Manager panel; this setting only applies to panels that never picked one." }, "kilo-code.new.indexing.showButtonWhenDisabled": { "type": "boolean", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 48817cf901..980f8e4272 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,4 +1,5 @@ import * as path from "path" +import { existsSync } from "fs" import * as vscode from "vscode" import type { KiloClient, @@ -161,8 +162,11 @@ import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" +import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" +import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/project/route" +import { indexingConsentStore, registeredProjects } from "./indexing-consent" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" import { fetchImageModels } from "./image-generation/models" import { stopSessionProcesses } from "./kilo-provider/background-process" @@ -172,7 +176,14 @@ import { validIndexingSetting, watchIndexingConfig, } from "./kilo-provider/indexing-settings" -import { buildChatSettingsMessage, validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings" +import { + ConfigBindings, + type ConfigBinding, + type ConfigProject, + type ConfigTarget, +} from "./kilo-provider/config-bindings" +import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project/paths" +import { validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings" import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings" let maxCost = 0 @@ -189,6 +200,10 @@ type SandboxSupportClient = { options: { throwOnError: true }, ) => Promise<{ data: { available: boolean; reason?: string } }> } +type ConfigSnapshot = { + effective: Config + targets: { global: ConfigTarget; project: ConfigTarget } +} function sandboxClient(client: KiloClient | null) { const sandbox = client?.sandbox @@ -331,6 +346,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null + private readonly configBindings = new ConfigBindings() private cachedGlobalConfig: Config | null = null /** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */ private cachedIndexingStatusMessage: unknown = null @@ -412,6 +428,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private ignoreControllerDir: string | null = null private chatAutocomplete: ChatTextAreaAutocomplete | null = null private projectDirectory: string | null | undefined + private indexingProjectId: string | undefined + private indexingSettingsRequest = 0 + private indexingStatusRequest = 0 private slimEditMetadata = true private pendingFollowup: Followup | null = null @@ -536,7 +555,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper public setProjectDirectory(directory: string | null): void { if (this.projectDirectory === directory) return this.projectDirectory = directory + this.configBindings.clear() + this.cachedConfigMessage = null this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" }) + this.postMessage({ type: "configBindingExpired", reason: "project-changed" }) this.requirements.clear() } @@ -799,14 +821,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * When set, all operations for this session use this directory instead of the workspace root. */ public setSessionDirectory(sessionId: string, directory: string): void { - this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) + const current = this.sessionDirectories.get(sessionId) ?? this.getRootDirectory() + this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), current) this.sessionDirectories.set(sessionId, directory) this.requirements.clear() if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId) } public clearSessionDirectory(sessionId: string): void { - this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) + const current = this.sessionDirectories.get(sessionId) ?? this.getRootDirectory() + this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), current) this.sessionDirectories.delete(sessionId) this.requirements.clear() if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId) @@ -817,11 +841,53 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return this.sessionDirectories } + /** Register a project root with the shared Agent Manager route service. */ + public registerProjectRoute(ref: ProjectRef, root: string, generation: number): void { + this.opts.routeService?.registerProject(ref.projectId, root, generation) + } + + /** Drop a project and all its session/worktree routes from the route service. */ + public unregisterProjectRoute(projectId: string): void { + this.opts.routeService?.unregisterProject(projectId) + } + + /** Register a worktree directory under a project. */ + public registerWorktreeRoute(ref: WorktreeRef, directory: string, generation: number): void { + this.opts.routeService?.registerWorktree(ref, directory, generation) + } + + /** Register a session directory under a project (exact routing). */ + public registerSessionRoute(ref: SessionRef, directory: string, generation: number): void { + this.opts.routeService?.registerSession(ref, directory, generation) + } + + /** Drop one session route, keeping the raw ambiguity index consistent. */ + public unregisterSessionRoute(ref: SessionRef): void { + this.opts.routeService?.unregisterSession(ref) + } + + /** Whether a raw session id is known to be ambiguous across projects. */ + public isSessionRouteAmbiguous(sessionId: string): boolean { + return this.opts.routeService?.isSessionAmbiguous(sessionId) ?? false + } + + /** Exact directory for a project-qualified session ref, or undefined. */ + public routeSessionDirectoryFor(ref: SessionRef): string | undefined { + return this.opts.routeService?.trySessionDirectoryFor(ref) + } + public async getSessionInfo(sessionId: string): Promise { await this.initializeConnection() const client = this.client if (!client) return - const directory = this.getWorkspaceDirectory(sessionId) + // Agent Manager: never query an ambiguous raw session id against the + // active root. The same id may exist in two projects, and the + // sessionDirectories map can only hold one entry per raw id, so falling + // back to the active root would silently resolve the wrong project's + // session. Require an exact route (qualified or unambiguous) instead. + const routed = this.routeSessionDirectory(sessionId) + if (routed === null) return undefined + const directory = routed ?? this.getWorkspaceDirectory(sessionId) return retry(() => client.session.get({ sessionID: sessionId, directory }, { throwOnError: true })) .then((result) => result.data) .catch((error: unknown) => { @@ -927,7 +993,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) this.indexingConfigDisposable?.dispose() - this.indexingConfigDisposable = watchIndexingConfig((msg) => this.postMessage(msg)) + this.indexingConfigDisposable = watchIndexingConfig(() => void this.sendIndexingSettings()) this.chatConfigDisposable?.dispose() this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg)) this.throughputConfigDisposable?.dispose() @@ -951,6 +1017,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper connection: this.connectionService, dir: this.getWorkspaceDirectory(this.currentSession?.id), post: (msg) => this.postMessage(msg), + browserSettings: () => this.sendBrowserSettings(), exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), @@ -1271,11 +1338,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] fetchAndSendIndexingStatus failed:", e), ) break - case "requestIndexingSettings": - this.postMessage(buildIndexingSettingsMessage()) + case "requestIndexingSettings": { + const project = await this.sendIndexingSettings(message.projectId) + if (message.projectId && project) await this.fetchAndSendIndexingStatus(project.root, project.id) break - case "requestChatSettings": - this.postMessage(buildChatSettingsMessage()) + } + case "setIndexingConsent": + await this.setIndexingConsent(message.projectId, message.enabled) break case "requestKiloEmbeddingModels": this.fetchAndSendKiloEmbeddingModels().catch((e) => @@ -1291,6 +1360,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.projectConfig, message.globalUnset, message.projectUnset, + message.globalBindingId, + message.projectBindingId, ) break case "openSettingsTab": @@ -1345,9 +1416,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "updateSetting": await this.handleUpdateSetting(message.key, message.value) break - case "requestBrowserSettings": - this.sendBrowserSettings() - break case "requestClaudeCompatSetting": this.sendClaudeCompatSetting() break @@ -1360,9 +1428,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestTimelineSetting": this.sendTimelineSetting() break - case "requestThroughputSetting": - this.postMessage(buildThroughputSettingMessage()) - break case "requestNotifications": this.fetchAndSendNotifications().catch((e) => console.error("[Kilo New] fetchAndSendNotifications failed:", e), @@ -1572,6 +1637,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionState = "connecting" this.connectionGeneration++ + this.configBindings.clear() this.postMessage({ type: "connectionState", state: "connecting" }) // Clean up any existing subscriptions (e.g., sidebar re-shown) @@ -1586,7 +1652,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.unsubscribeDirectoryProvider?.() try { - const workspaceDir = this.getWorkspaceDirectory() + const workspaceDir = this.settingsDirectory() // Connect the shared service (no-op if already connected) await this.connectionService.connect(workspaceDir) @@ -1637,7 +1703,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to connection state changes this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => { - if (this.connectionState !== state) this.connectionGeneration++ + if (this.connectionState !== state) { + this.connectionGeneration++ + this.configBindings.clear() + } this.connectionState = state this.postConnectionState(error) @@ -2562,24 +2631,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const workspaceDir = this.getWorkspaceDirectory() - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - - const message = { - type: "configLoaded", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - } - this.cachedConfigMessage = message - this.postMessage(message) + await this.refreshConfig("configLoaded") } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config:", error) } @@ -2597,8 +2649,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async fetchAndSendIndexingStatus(): Promise { - if (!this.client) { + private async fetchAndSendIndexingStatus(directory?: string, projectId?: string): Promise { + if (!this.client || !this.extensionContext) { if (this.cachedIndexingStatusMessage) { this.postMessage(this.cachedIndexingStatusMessage) } @@ -2607,21 +2659,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const config = this.connectionService.getServerConfig() if (!config) return + const request = ++this.indexingStatusRequest try { - const dir = this.getWorkspaceDirectory(this.currentSession?.id) - const auth = Buffer.from(`kilo:${config.password}`).toString("base64") - const res = await fetch(`${config.baseUrl}/indexing/status`, { - headers: { - Authorization: `Basic ${auth}`, - ...(dir ? { "x-kilo-directory": dir } : {}), - }, - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const status = (await res.json()) as IndexingStatus + const dir = directory ?? this.getWorkspaceDirectory(this.currentSession?.id) + if (!dir) return + const store = indexingConsentStore(this.extensionContext) + const project = await store.project(dir) + const status = await this.syncIndexingConsent(project.root, store.enabled(project.id), config) + if (request !== this.indexingStatusRequest) return const message = { type: "indexingStatusLoaded", status, + projectId, } this.cachedIndexingStatusMessage = message this.postMessage(message) @@ -2673,29 +2723,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async fetchAndSendConfigUpdated(): Promise { if (!this.client || this.connectionState !== "connected") return try { - const dir = this.getWorkspaceDirectory() - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null - this.cachedConfigMessage = { - type: "configLoaded", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - } - this.postMessage({ - type: "configUpdated", - config, - globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(config), - }) + await this.refreshConfig("configUpdated") } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -3021,6 +3049,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper project: Partial = {}, globalUnset: string[][] = [], projectUnset: string[][] = [], + globalBindingId?: string, + projectBindingId?: string, ): Promise { if (!this.client || this.connectionState !== "connected") { this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" }) @@ -3039,100 +3069,188 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper project.agent !== undefined const hasGlobal = Object.keys(partial).length > 0 || globalUnset.length > 0 const hasProject = Object.keys(project).length > 0 || projectUnset.length > 0 + if (!hasGlobal && !hasProject) return + + const globalBinding = hasGlobal + ? this.configBindings.get(globalBindingId, this.connectionGeneration, (project) => + this.validConfigProject(project), + ) + : undefined + const projectBinding = hasProject + ? this.configBindings.get(projectBindingId, this.connectionGeneration, (project) => + this.validConfigProject(project), + ) + : undefined + if ((hasGlobal && !globalBinding) || (hasProject && !projectBinding)) { + this.postMessage({ type: "configUpdateFailed", message: "Settings changed or expired. Reload before saving." }) + return + } this.pending++ - const dir = this.getWorkspaceDirectory() + const dir = projectBinding?.directory ?? globalBinding?.directory ?? this.settingsDirectory() + const completed: Array<"global" | "project"> = [] + let snapshot: ConfigSnapshot | undefined try { await this.connectionService.drainPendingPrompts() if (hasGlobal) { - await this.client.config.overlayUpdate( - { scope: "global", set: partial, unset: globalUnset, directory: dir }, + const result = await this.client.config.overlayUpdate( + { + scope: "global", + set: partial, + unset: globalUnset, + directory: globalBinding!.directory, + expected: { + path: globalBinding!.target.path, + revision: globalBinding!.target.revision, + }, + }, { throwOnError: true }, ) + snapshot = result.data as ConfigSnapshot + completed.push("global") } if (hasProject) { - await this.client.config.overlayUpdate( - { scope: "project", set: project, unset: projectUnset, directory: dir }, + const result = await this.client.config.overlayUpdate( + { + scope: "project", + set: project, + unset: projectUnset, + directory: projectBinding!.directory, + expected: { + path: projectBinding!.target.path, + revision: projectBinding!.target.revision, + }, + }, { throwOnError: true }, ) + snapshot = result.data as ConfigSnapshot + completed.push("project") } } catch (error) { - this.postConfigFailure(error) + if (completed.length > 0) { + if (globalBinding) this.configBindings.consume(globalBinding.id) + if (projectBinding) this.configBindings.consume(projectBinding.id) + } + this.postConfigFailure(error, completed, snapshot, dir) this.pending-- return } + if (globalBinding) this.configBindings.consume(globalBinding.id) + if (projectBinding) this.configBindings.consume(projectBinding.id) try { - const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([ - retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), - this.client.global.config.get({ throwOnError: true }), - this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), - ]) - this.cachedGlobalConfig = global ?? null + if (!snapshot) throw new Error("Config update returned no authoritative snapshot") + const bindings = this.bindingsFor(dir, snapshot.targets) + const global = snapshot.targets.global.raw as Config + const projectConfig = bindings.project ? (snapshot.targets.project.raw as Config) : undefined + this.cachedGlobalConfig = global this.cachedConfigMessage = { type: "configLoaded", - config: merged, + config: snapshot.effective, globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(merged), + projectConfig, + bindings, + settings: this.configSettings(), + features: configFeatures(snapshot.effective), } this.postMessage({ type: "configUpdated", - config: merged, + config: snapshot.effective, globalConfig: global, - projectConfig: overlay?.project, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: configFeatures(merged), + projectConfig, + bindings, + settings: this.configSettings(), + features: configFeatures(snapshot.effective), }) this.requirements.clear() await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), refreshAgents ? this.fetchAndSendAgents() : Promise.resolve(), - ]) + ]).catch((error) => console.error("[Kilo New] KiloProvider: Post-config refresh failed:", error)) } catch (error) { - console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) - const patch = - partial.indexing === undefined && project.indexing === undefined - ? { ...partial, ...project } - : { ...partial, ...project, indexing: { ...(partial.indexing ?? {}), ...(project.indexing ?? {}) } } - const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config - const features = (this.cachedConfigMessage as { features?: unknown } | null)?.features - const optimistic = - cached && typeof cached === "object" ? { ...(cached as Record), ...patch } : patch - this.postMessage({ - type: "configUpdated", - config: optimistic, - globalConfig: this.cachedGlobalConfig ?? undefined, - settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() }, - features: features ?? configFeatures(optimistic as Config), - }) - this.requirements.clear() + this.postConfigFailure(error, completed, snapshot, dir) } finally { this.pending-- } } - private postConfigFailure(error: unknown): void { + private async refreshConfig(type: "configLoaded" | "configUpdated", dir = this.settingsDirectory()) { + const snapshot = await fetchSnapshot(this.client!, dir, () => this.configSettings()) + const bindings = this.bindingsFor(dir, snapshot.targets) + const globalConfig = (snapshot.targets?.global.raw ?? snapshot.globalConfig) as Config + const projectConfig = bindings.project ? (snapshot.targets?.project.raw as Config) : undefined + this.cachedGlobalConfig = globalConfig ?? null + this.cachedConfigMessage = { + type: "configLoaded", + config: snapshot.config, + globalConfig, + projectConfig, + bindings, + collections: snapshot.collections, + settings: snapshot.settings, + features: snapshot.features, + } + this.postMessage({ + type, + config: snapshot.config, + globalConfig, + projectConfig, + bindings, + collections: snapshot.collections, + settings: snapshot.settings, + features: snapshot.features, + }) + } + + private postConfigFailure( + error: unknown, + completed: Array<"global" | "project"> = [], + snapshot?: ConfigSnapshot, + directory?: string, + ): void { console.error("[Kilo New] KiloProvider: Failed to update config:", error) + const bindings = snapshot && directory ? this.bindingsFor(directory, snapshot.targets) : undefined this.postMessage({ type: "configUpdateFailed", message: getErrorMessage(error) || "Failed to update config", details: getConfigErrorDetails(error), + completedScopes: completed, + config: snapshot?.effective, + globalConfig: snapshot?.targets.global.raw, + projectConfig: bindings?.project ? snapshot?.targets.project.raw : undefined, + bindings, }) } private async resolveSession(sessionID?: string, draftID?: string, context?: string, contextDirectory?: string) { if (!this.client) return undefined - const dir = resolveNewSessionDirectory({ - sessionID, - currentSessionID: this.currentSession?.id, - contextSessionID: this.contextSessionID, - agentManagerContext: context, - contextDirectory, - sessionDirectories: this.sessionDirectories, - workspaceDirectory: this.getRootDirectory(), - }) + // Agent Manager: consult the shared route service for an exact directory + // before the legacy sessionDirectories/workspace-root resolution. When a + // raw session id is ambiguous across projects, refuse to resolve rather + // than fall back to the active root or a stale single-entry map — share, + // unshare, send, and other command paths route through here. A project + // qualifier (when configured) disambiguates to the exact directory. + let routedDir: string | null | undefined = undefined + if (sessionID) { + routedDir = this.routeSessionDirectory(sessionID) + if (routedDir === null) { + throw new Error( + `Session ${sessionID} is ambiguous across projects; cannot resolve a directory without a project qualifier.`, + ) + } + } + + const dir = + routedDir ?? + resolveNewSessionDirectory({ + sessionID, + currentSessionID: this.currentSession?.id, + contextSessionID: this.contextSessionID, + agentManagerContext: context, + contextDirectory, + sessionDirectories: this.sessionDirectories, + workspaceDirectory: this.getRootDirectory(), + }) const key = `${draftID ?? context ?? "new"}\0${dir}` const now = Date.now() @@ -3276,6 +3394,134 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return vscode.workspace.getConfiguration("kilo-code.new").get("languageCommitMessage", "sync") } + private multiProjectSetting(): boolean { + return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false) + } + + private async sendIndexingSettings(projectId?: string) { + if (!this.extensionContext) { + this.postMessage(buildIndexingSettingsMessage()) + return undefined + } + const request = ++this.indexingSettingsRequest + const store = indexingConsentStore(this.extensionContext) + const projects = await store.list(this.getRootDirectory(), registeredProjects(this.extensionContext)) + if (request !== this.indexingSettingsRequest) return undefined + const id = projects.some((project) => project.id === projectId) + ? projectId + : projects.some((project) => project.id === this.indexingProjectId) + ? this.indexingProjectId + : projects[0]?.id + this.indexingProjectId = id + this.postMessage(buildIndexingSettingsMessage(id ? store.enabled(id) : false, projects, id)) + return projects.find((project) => project.id === id) + } + + private async setIndexingConsent(projectId: string, enabled: boolean): Promise { + if (!this.extensionContext) return + const store = indexingConsentStore(this.extensionContext) + const projects = await store.list(this.getRootDirectory(), registeredProjects(this.extensionContext)) + const project = projects.find((item) => item.id === projectId) + if (!project) return + await store.set(project.id, enabled) + this.indexingProjectId = project.id + await this.sendIndexingSettings(project.id) + const config = this.connectionService.getServerConfig() + if (!config) return + const request = ++this.indexingStatusRequest + const status = await this.syncIndexingConsent(project.root, enabled, config) + if (request !== this.indexingStatusRequest || this.indexingProjectId !== project.id) return + const message = { type: "indexingStatusLoaded", status, projectId: project.id } + this.cachedIndexingStatusMessage = message + this.postMessage(message) + } + + private async syncIndexingConsent( + dir: string, + enabled: boolean, + config: { baseUrl: string; password: string }, + ): Promise { + const auth = Buffer.from(`kilo:${config.password}`).toString("base64") + const res = await fetch(`${config.baseUrl}/indexing/consent`, { + method: "PUT", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + "x-kilo-directory": dir, + }, + body: JSON.stringify({ enabled }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as IndexingStatus + } + + private configSettings() { + return { + maxCost: this.maxCostSetting(), + languageCommitMessage: this.commitMessageLanguageSetting(), + multiProject: this.multiProjectSetting(), + } + } + + private settingsDirectory(): string { + return this.projectDirectory ?? this.getRootDirectory() + } + + private bindingsFor( + directory: string, + targets: + | { + global: ConfigTarget + project: ConfigTarget + } + | undefined, + ): { global?: ConfigBinding; project?: ConfigBinding } { + if (!targets) return {} + const project = this.configProject(directory) + return { + global: this.configBindings.create({ + connection: this.connectionGeneration, + scope: "global", + directory, + target: this.configTarget(targets.global), + }), + project: project + ? this.configBindings.create({ + connection: this.connectionGeneration, + scope: "project", + directory: project.root, + target: this.configTarget(targets.project), + project, + }) + : undefined, + } + } + + private configTarget(target: ConfigTarget): ConfigTarget { + return { ...target, raw: { ...target.raw } } + } + + private configProject(directory: string): ConfigProject | undefined { + const root = canonicalizePath(directory) + const pinned = canonicalizePath(this.getRootDirectory()) + if (samePath(root, pinned)) { + return { id: projectIdFor(root), root, generation: 0, pinned: true } + } + if (!this.extensionContext) return undefined + const project = registeredProjects(this.extensionContext).find((item) => samePath(item.root, root)) + if (!project?.trusted || !existsSync(project.root)) return undefined + return { id: project.id, root: project.root, generation: 0, pinned: false } + } + + private validConfigProject(project: ConfigProject): boolean { + if (project.pinned) { + return samePath(project.root, canonicalizePath(this.getRootDirectory())) && existsSync(project.root) + } + if (!this.extensionContext) return false + const current = registeredProjects(this.extensionContext).find((item) => item.id === project.id) + return !!current?.trusted && samePath(current.root, project.root) && existsSync(current.root) + } + private setMaxCost(value: unknown): number { maxCost = MaxCostNudge.normalizeLimit(typeof value === "number" ? value : Number(value)) ?? 0 this.costs.setLimit(maxCost) @@ -3795,7 +4041,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Re-send all settings to the webview so the UI reflects the reset this.postMessage(buildAutocompleteSettingsMessage()) - this.postMessage(buildIndexingSettingsMessage()) + await this.sendIndexingSettings() this.sendBrowserSettings() this.sendNotificationSettings() this.sendTimelineSetting() @@ -4426,6 +4672,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private getWorkspaceDirectory(sessionId?: string): string { + const routed = this.routeSessionDirectory(sessionId ?? undefined) + // Ambiguous ids degrade to the legacy resolution instead of throwing: this + // runs eagerly per webview message, where a throw would drop the message. + if (routed === null) + console.warn(`[Kilo New] KiloProvider: session ${sessionId} is ambiguous across projects, using workspace root`) + if (routed) return routed return resolveWorkspaceDirectory({ sessionID: sessionId, sessionDirectories: this.sessionDirectories, @@ -4434,9 +4686,44 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private getSessionDirectory(sessionId: string, session?: Session): string { + const routed = this.routeSessionDirectory(sessionId) + if (routed === null) + console.warn( + `[Kilo New] KiloProvider: session ${sessionId} is ambiguous across projects, using tracked directory`, + ) + if (routed) return routed return this.sessionDirectories.get(sessionId) ?? session?.directory ?? this.getRootDirectory() } + /** + * Resolve a session directory through the Agent Manager route service. + * + * Returns the exact directory when the route service knows the id and it is + * unambiguous (or a project qualifier disambiguates it). Returns `undefined` + * when no route service is configured or the id is unknown — callers then + * fall through to the legacy sessionDirectories/workspace-root path, which + * preserves non-Agent-Manager behavior. Returns `null` when the id is + * ambiguous and cannot be qualified: callers must NOT fall back to an + * arbitrary root in that case, because doing so would silently target the + * wrong project. + */ + private routeSessionDirectory(sessionId: string | undefined): string | null | undefined { + const routes = this.opts.routeService + if (!routes || !sessionId) return undefined + const exact = routes.trySessionDirectory(sessionId) + if (exact) return exact + if (routes.isSessionAmbiguous(sessionId)) { + const qualifier = this.opts.projectQualifier?.() + if (qualifier) { + const ref: SessionRef = { projectId: qualifier.projectId, sessionId } + const dir = routes.trySessionDirectoryFor(ref) + if (dir) return dir + } + return null + } + return undefined + } + private getContextDirectory(): string { return resolveContextDirectory({ currentSessionID: this.currentSession?.id, @@ -4447,6 +4734,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private getRootDirectory(): string { + const override = this.opts.rootDirectory?.() + if (override) return override const workspaceFolders = vscode.workspace.workspaceFolders if (workspaceFolders && workspaceFolders.length > 0) { return workspaceFolders[0]!.uri.fsPath @@ -4455,10 +4744,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private trackDirectory(sessionId: string, dir: string) { - if (path.resolve(dir) === path.resolve(this.getRootDirectory())) { - this.sessionDirectories.delete(sessionId) - return - } + // Agent Manager Local sessions must retain their explicit project root. + // Removing same-root entries would silently retarget them after switching + // the panel's active project. this.sessionDirectories.set(sessionId, dir) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index bf72af74ab..59a8ba8951 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -2,29 +2,43 @@ import * as fs from "fs" import * as path from "path" import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" -import { getErrorMessage } from "../kilo-provider-utils" +import { getErrorMessage, sessionToWebview } from "../kilo-provider-utils" +import { samePath } from "./project/paths" import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DiffSourceCatalog } from "../diff/sources/catalog" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager" +import { composeDiffId, normalizeScope } from "./diff-scope" import { handleSection } from "./section-handler" +import { STATE_GATED } from "./project/state-gate" +import { + addSessionToLifecycleWorktree, + closeLifecycleSession, + createLifecycleWorktree, + deleteLifecycleWorktree, + promoteLifecycleSession, + removeStaleLifecycleWorktree, + type LifecycleHost, +} from "./provider-lifecycle" import { normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" import { PRStatusBridge } from "./pr-status-bridge" +import { createPollers, type ProjectPollers } from "./project/pollers" import { GitOps } from "./GitOps" import { versionedName } from "./branch-name" import { BranchNamingController } from "./branch-naming" import { SetupScriptService } from "./SetupScriptService" -import { SetupScriptRunner } from "./SetupScriptRunner" import { copyEnvFiles } from "./env-copy" import { SessionTerminalManager } from "./SessionTerminalManager" import { createTerminalHost } from "./terminal-host" import { TerminalRouter } from "./terminal-routing" import { executeVscodeTask } from "./task-runner" -import { startVscodeRunTask } from "./run/task" +import { runWorktreeSetupScript } from "./setup-script-task" import { RunController } from "./run/controller" import { handleRunMessage } from "./run/message" +import { createRunController, createScriptTerminalRuntime, clearScriptTerminals } from "./script-terminal-runtime" import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" @@ -35,51 +49,64 @@ import { type CreateWorktreeOnDiskOptions, type CreateWorktreeOnDiskResult, } from "./worktree-create" -import { recordPromotionHandoff } from "./promotion-handoff" -import { restoreWorktrees } from "./state-recovery" -import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff" +import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init" +import { createLocalDiff } from "./local-diff" import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" -import { stopSessionProcesses } from "../kilo-provider/background-process" import { sandboxSessionMetadata } from "../shared/sandbox-session" import { AgentManagerOrchestrationBridge } from "./orchestration-bridge" import { pruneSubagents } from "./prune-subagents" - import { startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" -import { readTerminalDestination, watchTerminalDestination } from "./terminal-destination" +import { DestinationState, handleDestination, watchTerminalDestination } from "./terminal-destination" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" import { Semaphore } from "./semaphore" import { PLATFORM } from "./constants" +import { ProjectRegistry } from "./project/registry" +import type { ProjectContext } from "./project/context" +import { ProjectContexts } from "./project/contexts" +import { createMultiVersion, type MultiVersionHost } from "./provider-multi-version" +import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages" +import { createProjectWiring } from "./project/wiring" +import { ProjectScope } from "./project/scope" import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types" import type { Host, PanelContext, OutputHandle, Disposable } from "./host" + export class AgentManagerProvider implements Disposable { public static readonly viewType = "kilo-code.new.AgentManagerPanel" private panel: PanelContext | undefined private outputChannel: OutputHandle - private worktrees: WorktreeManager | undefined - private state: WorktreeStateManager | undefined - private setupScript: SetupScriptService | undefined + private readonly registry: ProjectRegistry + private readonly contexts: ProjectContexts + private readonly projects: ProjectMessageDeps + private readonly projectScope = new ProjectScope() private importer: WorktreeImporter private terminalManager: SessionTerminalManager private terminalRouter: TerminalRouter + private scripts: ReturnType private run: RunController private stateReady: Promise | undefined private statsPoller: GitStatsPoller + private readonly projectPollers: ProjectPollers private prBridge!: PRStatusBridge private orchestration: AgentManagerOrchestrationBridge private gitOps: GitOps private diffs: WorktreeDiffController + private diffCatalog: DiffSourceCatalog private naming: BranchNamingController - private staleWorktreeIds = new Set() private toolRequests = new Set() private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined private unsubTool: (() => void) | undefined private unsubStatus: (() => void) | undefined + private unsubSessions: (() => void) | undefined private unsubFont: (() => void) | undefined + private unsubProjects: (() => void) | undefined + /** Scratch set returned when no active context exists; mutations are discarded. */ + private readonly staleScratch = new Set() private unsubDestination: (() => void) | undefined + private destination = new DestinationState() private closing: Promise | undefined private onVisibilityChange: ((visible: boolean) => void) | undefined // Tracks sessions owned by this panel until they are explicitly closed. @@ -103,6 +130,7 @@ export class AgentManagerProvider implements Disposable { ) this.terminalRouter = new TerminalRouter({ getClient: () => this.connectionService.getClient(), + getClientAsync: () => this.connectionService.getClientAsync(this.getRoot()), getServerConfig: () => this.connectionService.getServerConfig() ?? undefined, getRoot: () => this.getRoot(), getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path, @@ -110,19 +138,27 @@ export class AgentManagerProvider implements Disposable { post: (msg) => this.postToWebview(msg), getTerminalFont: () => readTerminalFont(), }) + this.scripts = createScriptTerminalRuntime({ + connection: this.connectionService, + output: this.outputChannel, + post: (message) => this.postToWebview(message), + }) this.unsubFont = watchTerminalFont((font) => { this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) + this.scripts.manager.snapshot() }) this.unsubDestination = watchTerminalDestination((destination) => { + this.destination.sync(destination) this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) }) - this.run = new RunController({ + this.run = createRunController({ + manager: this.scripts.manager, root: () => this.getRoot(), state: () => this.getStateManager(), + project: (id) => this.projectForScript(id), open: (file) => this.host.openDocument(file), - start: startVscodeRunTask, - post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }), - error: (message) => this.postToWebview({ type: "error", message }), + trusted: () => this.host.isTrusted(), + post: (message) => this.postRunMessage(message), log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`), refresh: () => this.pushState(), }) @@ -139,6 +175,23 @@ export class AgentManagerProvider implements Disposable { }) const semaphore = new Semaphore(3) this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) + const wiring = createProjectWiring({ + host: this.host, + git: this.gitOps, + log: (...args) => this.log(...args), + output: (msg) => this.outputChannel.appendLine(msg), + activate: (ctx) => this.activateProject(ctx), + expand: (ctx) => this.initExpanded(ctx), + ready: (ctx) => initContextState(ctx, (...args) => this.log(...args)), + push: () => this.pushProjects(), + changed: () => this.onWorkspaceChanged(), + refresh: () => this.pushState(), + selected: (target) => this.postToWebview({ type: "agentManager.selectionActivated", target }), + }) + this.registry = wiring.registry + this.contexts = wiring.contexts + this.projects = wiring.messages + this.unsubProjects = () => wiring.dispose() this.naming = new BranchNamingController({ state: () => this.getStateManager(), manager: () => this.getWorktreeManager(), @@ -148,47 +201,36 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) + this.diffCatalog = new DiffSourceCatalog(this.connectionService) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, + catalog: this.diffCatalog, git: this.gitOps, - localDiff: local.summary, localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), }) - this.statsPoller = new GitStatsPoller({ - getWorktrees: () => this.state?.getWorktrees() ?? [], - getWorkspaceRoot: () => this.getRoot(), - localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), - semaphore, - onStats: (stats) => { - const msg = { type: "agentManager.worktreeStats" as const, stats } - this.cachedWorktreeStats = msg - this.postToWebview(msg) - }, - onLocalStats: (stats) => { - const msg = { type: "agentManager.localStats" as const, stats } - this.cachedLocalStats = msg - this.postToWebview(msg) - }, - onWorktreePresence: (presence) => { - this.onWorktreePresence(presence) - }, - log: (...args) => this.log(...args), + const pollers = createPollers({ git: this.gitOps, - }) - this.prBridge = PRStatusBridge.create({ - getWorktrees: () => this.state?.getWorktrees() ?? [], - getWorkspaceRoot: () => this.getRoot(), - postToWebview: (m) => this.postToWebview(m), - updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s), - hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber, - openExternal: (u) => this.host.openExternal(u), - log: (...a) => this.log(...a), semaphore, + state: () => this.state, + root: () => this.getRoot(), + activeId: () => this.contexts.active()?.id, + visible: () => this.panel?.visible ?? false, + post: (msg) => this.postToWebview(msg), + cache: (msg) => { + if (msg.type === "agentManager.worktreeStats") this.cachedWorktreeStats = msg + if (msg.type === "agentManager.localStats") this.cachedLocalStats = msg + }, + presence: (presence) => this.onWorktreePresence(presence), + openExternal: (u) => this.host.openExternal(u), + log: (...args) => this.log(...args), }) + this.statsPoller = pollers.stats + this.prBridge = pollers.pr + this.projectPollers = pollers.projects this.orchestration = new AgentManagerOrchestrationBridge(this.connectionService, { root: () => this.getRoot(), state: () => this.state, @@ -214,6 +256,48 @@ export class AgentManagerProvider implements Disposable { (event) => (event as { type?: string }).type === "session.status", (event) => this.onSessionStatus(event), ) + this.unsubSessions = this.connectionService.onEventFiltered( + (event) => { + const type = (event as { type?: string }).type + return type === "session.created" || type === "session.updated" || type === "session.deleted" + }, + (event) => this.onSessionLifecycle(event), + ) + } + + /** + * Keep each project's cached sidebar session list in sync with backend + * session lifecycle events, so sessions created outside this panel (another + * window, the CLI, the API) appear without waiting for a full re-list. + */ + private onSessionLifecycle(event: unknown): void { + const ev = event as { type?: string; properties?: { info?: Session; sessionID?: string } } + if (ev.type === "session.deleted") { + const id = ev.properties?.sessionID + if (!id) return + const ctx = this.contexts.byLiveSession(id) + if (!ctx) return + ctx.removeLiveSession(id) + this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] }) + return + } + const info = ev.properties?.info + const dir = info?.directory + // Session events from sync or older backends can lack time/directory; a + // throw here would escape into the SSE dispatch loop and starve the other + // listeners (there is no per-listener error isolation). + if (!info?.time || !dir) return + const ctx = this.contexts.byDirectory(dir) + if (!ctx || ctx.lifecycle !== "ready") return + const state = ctx.peekState() + const managed = state?.getSession(info.id) + const worktreeId = + managed?.worktreeId ?? state?.getWorktrees().find((wt) => wt.path && samePath(wt.path, dir))?.id ?? null + ctx.upsertSession({ ...sessionToWebview(info), worktreeId }) + // The next regular push re-lists from the backend to reconcile the + // optimistic entry (position, subtrees, deletions elsewhere). + ctx.invalidateSessions() + this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] }) } private onSessionStatus(event: unknown): void { @@ -244,6 +328,8 @@ export class AgentManagerProvider implements Disposable { this.host.openPanel({ onBeforeMessage: (msg) => this.onMessage(msg), worktreeDirectories: () => this.getWorktreeDirectories(), + workspaceRoot: () => this.getRoot(), + projectId: () => this.contexts.active()?.id, }), ) } @@ -280,9 +366,11 @@ export class AgentManagerProvider implements Disposable { this.panel = ctx this.statsPoller.setVisible(ctx.visible) + this.projectPollers.setVisible(ctx.visible) this.onVisibilityChange?.(ctx.visible) ctx.onDidChangeVisibility((visible) => { this.statsPoller.setVisible(visible) + this.projectPollers.setVisible(visible) this.visiblePresence.flush() }) @@ -291,6 +379,7 @@ export class AgentManagerProvider implements Disposable { }) this.stateReady = this.initializeState() + this.pushProjects() void this.sendRepoInfo() this.sendKeybindings() this.prBridge.attachPanel(ctx) @@ -316,47 +405,39 @@ export class AgentManagerProvider implements Disposable { }) } - // State initialization - private async initializeState(): Promise { - const manager = this.getWorktreeManager() - const state = this.getStateManager() - if (!manager || !state) { + const ctx = this.contexts.active() + if (!ctx) { this.pushEmptyState() return } + const state = ctx.stateManager() + const init = await initContextState(ctx, (...args) => this.log(...args)) - await this.ensureGitExclude(manager) - const loaded = await state.load() - manager.cleanupOrphanedTempDirs() - - if (loaded.status === "failed" && !(await state.prepareRecovery())) { + if (!init.ok) { this.postToWebview({ type: "error", message: "Agent Manager state could not be recovered." }) this.pushState() return } - await this.recoverWorktrees(manager, state) - // When the .kilocode → .kilo migration rewrote git worktree refs, nudge // VS Code's git extension to re-discover them. Without this, worktrees // won't appear in Source Control until the next VS Code restart. - if (loaded.refsFixed > 0) { - this.log(`Migration fixed ${loaded.refsFixed} git worktree ref(s), refreshing git`) + if (init.refsFixed > 0) { + this.log(`Migration fixed ${init.refsFixed} git worktree ref(s), refreshing git`) this.host.refreshGit() } - for (const wt of state.getWorktrees()) { - for (const s of state.getSessions(wt.id)) this.panel?.sessions.setSessionDirectory(s.id, wt.path) - } + registerProjectSessions(ctx, this.panel?.sessions) await pruneSubagents(state, this.panel?.sessions, (message) => this.log(message)) for (const s of state.getSessions()) this.panel?.sessions.trackSession(s.id) this.pushState() - // Refresh sessions so worktree sessions appear in the list - if (state.getSessions().length > 0) { - this.panel?.sessions.refreshSessions() - } + // Always list sessions, even when the state tracks none: the backend may + // still hold sessions for this project, and without the listing the + // sessionsLoaded message never reaches the webview, leaving the sidebar + // on skeletons forever. + this.panel?.sessions.refreshSessions() // Recover any pending permission/question prompts that were missed during // panel recreation or SSE reconnection. Must run after all worktree sessions @@ -365,26 +446,16 @@ export class AgentManagerProvider implements Disposable { this.panel?.sessions.recoverPendingPrompts() } - private async ensureGitExclude(manager: WorktreeManager): Promise { - await manager.ensureGitExclude().catch((err) => { - this.log("Failed to update git exclude:", err) + /** Initialize an expanded background project and push its state (no panel wiring). */ + private initExpanded(ctx: ProjectContext): void { + void initContextState(ctx, (...args) => this.log(...args)).then((result) => { + if (!result.current) return + registerProjectSessions(ctx, this.panel?.sessions) + this.pushState(ctx) + this.projectPollers.sync(this.contexts) }) } - private async recoverWorktrees(manager: WorktreeManager, state: WorktreeStateManager): Promise { - const infos = await manager.discoverWorktrees().catch((err) => { - this.log("Failed to discover worktrees during state recovery:", err) - return [] - }) - if (infos.length === 0) return - - const result = restoreWorktrees(state, infos) - if (result.worktrees === 0 && result.sessions === 0) return - - this.log(`Recovered ${result.worktrees} worktree(s) and ${result.sessions} session(s) from disk`) - await state.flush() - } - // Message interceptor private async onMessage(msg: Record): Promise | null> { @@ -394,7 +465,27 @@ export class AgentManagerProvider implements Disposable { } msg = await this.contextMessage(msg) const m = msg as unknown as AgentManagerInMessage - if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type) + if (await handleProjectMessage(m, this.projects)) return null + const ctx = this.messageProject(m) + if (!ctx) { + this.log(`dropping ${m.type}: message targets an unknown project`) + return null + } + return this.projectScope.run(ctx, () => this.dispatchMessage(m, msg, ctx)) + } + + private async dispatchMessage( + m: AgentManagerInMessage, + msg: Record, + ctx: ProjectContext, + ): Promise | null> { + if (this.shouldWaitForState(m)) { + const result = await initContextState(ctx, (...args) => this.log(...args)) + if (!result.current || !result.ok) { + this.log(`dropping ${m.type}: project ${ctx.id} not ready (current=${result.current}, ok=${result.ok})`) + return null + } + } this.onBranchPrompt(m) const worktree = await this.onWorktreeMessage(m) @@ -411,6 +502,7 @@ export class AgentManagerProvider implements Disposable { if (diff !== undefined) return diff const bridge = this.onBridgeMessage(m) if (bridge !== undefined) return bridge + if (this.scripts.manager.intercept(m)) return null if (this.terminalRouter.handle(m)) return null return msg @@ -584,7 +676,8 @@ export class AgentManagerProvider implements Disposable { void this.configureSetupScript() return null } - if (handleRunMessage(this.run, m)) return null + if (handleDestination(this.destination, m, (msg) => this.log("[XTerm]", msg))) return null + if (handleRunMessage(this.run, m, (id) => this.runKey(id))) return null if (m.type === "agentManager.showTerminal") { this.terminalManager.showTerminal(m.sessionId, this.state) return null @@ -593,6 +686,10 @@ export class AgentManagerProvider implements Disposable { this.terminalManager.showLocalTerminal() return null } + if (m.type === "agentManager.showWorktreeTerminal") { + this.terminalManager.showWorktreeTerminal(m.worktreeId, this.state) + return null + } if (m.type === "agentManager.openWorktree") { this.openWorktreeDirectory(m.worktreeId) return null @@ -640,6 +737,10 @@ export class AgentManagerProvider implements Disposable { } if (m.type === "agentManager.setSessionsCollapsed") { this.state?.setSessionsCollapsed(m.collapsed) + // Multi-project bodies render collapsed purely from pushed state, so the + // mutation must round-trip; legacy mode is covered by its optimistic + // signal and the push is a no-op update. + this.pushState() return null } if (m.type === "agentManager.setSidebarCollapsed") { @@ -679,11 +780,11 @@ export class AgentManagerProvider implements Disposable { private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestWorktreeDiff") { - void this.diffs.request(m.sessionId) + void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.requestWorktreeDiffFile") { - void this.diffs.requestFile(m.sessionId, m.file) + void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope), m.diffSessionId), m.file) return null } if (m.type === "agentManager.applyWorktreeDiff") { @@ -691,23 +792,53 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.revertWorktreeFile") { - void this.diffs.revert(m.sessionId, m.file) + void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.startDiffWatch") { - this.diffs.start(m.sessionId) + this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope), m.diffSessionId)) return null } if (m.type === "agentManager.stopDiffWatch") { this.diffs.stop() return null } + if (m.type === "agentManager.requestDiffBranches") { + void this.sendDiffBranches(m.sessionId, m.scope) + return null + } + if (m.type === "agentManager.setDiffBaseBranch") { + void this.diffs + .setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch) + .catch((err) => this.log("Failed to set diff base:", err instanceof Error ? err.message : String(err))) + .then(() => void this.sendDiffBranches(m.sessionId, m.scope)) + return null + } if (m.type === "agentManager.openFile") { this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column) return null } } + private async sendDiffBranches(sessionId: string, scope?: string): Promise { + const id = composeDiffId(sessionId, normalizeScope(scope)) + const result = await this.diffs.branches(id).catch((err) => { + this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err)) + return undefined + }) + if (!result) return + this.postToWebview({ + type: "agentManager.diffBranches", + sessionId: id, + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type !== "openFile") return undefined @@ -720,6 +851,17 @@ export class AgentManagerProvider implements Disposable { } private onRequestState(): void { + // requestState fires from the webview's onMount, and a freshly mounted + // webview has no terminal records — any PTYs the router still tracks + // belong to a previous webview instance (reload or crash) and are + // unreachable orphans. Kill them here rather than leaking shells until + // the panel itself is disposed. In-flight creates from the dying + // instance are reaped by the router's generation guard. + void this.terminalRouter.dispose() + this.scripts.manager.snapshot() + this.log( + `onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`, + ) void this.stateReady ?.then(() => { // When the folder is not a git repo (or has no folder open), @@ -740,9 +882,9 @@ export class AgentManagerProvider implements Disposable { // onMount). Without this, the initial refreshSessions() in // initializeState() can race ahead of webview mount, causing // sessionsLoaded to never flip to true. - if (this.state.getSessions().length > 0) { - this.panel?.sessions.refreshSessions() - } + // Unconditional: an empty managed list still needs the backend listing + // so the webview's sessionsLoaded gate can flip. + this.panel?.sessions.refreshSessions() }) .catch((err) => { this.log("initializeState failed, pushing partial state:", err) @@ -876,41 +1018,21 @@ export class AgentManagerProvider implements Disposable { } private async waitForStateReady(context: string): Promise { + const ctx = this.projectScope.current() + // A message scoped to a background project must wait for that project's + // own initialization; this.stateReady only tracks the active project. + if (ctx && ctx.id !== this.contexts.active()?.id) { + const result = await initContextState(ctx, (...args) => this.log(...args)) + if (!result.ok) this.log(`${context}: project ${ctx.id} state did not load`) + return + } if (!this.stateReady) return await this.stateReady.catch((err) => this.log(`${context}: stateReady rejected, continuing:`, err)) } private shouldWaitForState(m: AgentManagerInMessage): boolean { - switch (m.type) { - case "agentManager.deleteWorktree": - case "agentManager.removeStaleWorktree": - case "agentManager.openLocally": - case "agentManager.addSessionToWorktree": - case "agentManager.closeSession": - case "agentManager.persistSession": - case "agentManager.forgetSession": - case "agentManager.renameWorktree": - case "agentManager.requestBranches": - case "agentManager.importFromBranch": - case "agentManager.importFromPR": - case "agentManager.setTabOrder": - case "agentManager.setWorktreeOrder": - case "agentManager.setSessionsCollapsed": - case "agentManager.setSidebarCollapsed": - case "agentManager.setReviewDiffStyle": - case "agentManager.setDefaultBaseBranch": - case "agentManager.createSection": - case "agentManager.renameSection": - case "agentManager.deleteSection": - case "agentManager.setSectionColor": - case "agentManager.toggleSectionCollapsed": - case "agentManager.moveToSection": - case "agentManager.moveSection": - case "agentManager.terminal.create": - return true - default: - return false - } + if (m.type === "agentManager.terminal.create") return m.worktreeId !== null + return STATE_GATED.has(m.type) } private onToolEvent(event: unknown, directory?: string): void { @@ -951,7 +1073,7 @@ export class AgentManagerProvider implements Disposable { registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid), push: () => this.pushState(), - post: (msg) => this.postToWebview(msg as AgentManagerOutMessage), + post: (msg) => this.postToWebview(msg as unknown as AgentManagerOutMessage), capture: (event, props) => this.host.capture(event, props), log: (...args) => this.log(...args), error: (msg) => this.host.showError(msg), @@ -964,221 +1086,37 @@ export class AgentManagerProvider implements Disposable { /** Create a new worktree with an auto-created first session. */ private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise { - await this.waitForStateReady("onCreateWorktree") - - const created = await this.createWorktreeOnDisk({ baseBranch, branchName }) - if (!created) return null - - // Run setup script for new worktree (blocks until complete, shows in overlay) - await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id) - - const session = await this.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id) - if (!session) { - const state = this.getStateManager() - const manager = this.getWorktreeManager() - state?.removeWorktree(created.worktree.id) - await manager?.removeWorktree(created.result.path) - this.pushState() - return null - } - - const state = this.getStateManager()! - state.addSession(session.id, created.worktree.id) - if (!branchName && this.host.autoBranchNaming().enabled) state.armAutoName(created.worktree.id, session.id) - this.registerWorktreeSession(session.id, created.result.path) - // Push state before registerSession so the webview's sessionCreated handler - // sees the worktree mapping and routes the session to the worktree tab. - this.notifyWorktreeReady(session.id, created.result, created.worktree.id) - this.panel?.sessions.registerSession(session) - this.host.capture("Agent Manager Session Started", { - source: PLATFORM, - sessionId: session.id, - worktreeId: created.worktree.id, - branch: created.result.branch, - }) - this.log(`Created worktree ${created.worktree.id} with session ${session.id}`) - return null + const ctx = this.context + if (!ctx) return null + return createLifecycleWorktree(ctx, this.lifecycleHost, { baseBranch, branchName }) } /** Delete a worktree and dissociate its sessions. */ private async onDeleteWorktree(worktreeId: string): Promise { - const manager = this.getWorktreeManager() - const state = this.getStateManager() - if (!manager || !state) return null - const worktree = state.getWorktree(worktreeId) - if (!worktree) { - this.log(`Worktree ${worktreeId} not found in state`) - return null - } - // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. - // Pre-emptive skip covers any in-flight poll that already captured getWorktrees(). - this.statsPoller.skipWorktree(worktreeId) - this.prBridge.remove(worktreeId) - this.run.remove(worktreeId) - this.naming.forget(worktreeId) - const orphaned = state.removeWorktree(worktreeId) - if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { - this.diffs.stop() - } - for (const s of orphaned) this.panel?.sessions.clearSessionDirectory(s.id) - this.pushState() - // Disk removal after state is clean — pollers no longer reference this worktree. - const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch) - try { - await manager.removeWorktree(worktree.path, branch) - } catch (error) { - this.log(`Failed to remove worktree from disk: ${error}`) - } - this.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`) - return null + const ctx = this.context + if (!ctx) return null + return deleteLifecycleWorktree(ctx, this.lifecycleHost, worktreeId) } /** Remove a stale worktree entry from state without touching the filesystem. */ private async onRemoveStaleWorktree(worktreeId: string): Promise { - const state = this.getStateManager() - if (!state) return null - if (!this.staleWorktreeIds.has(worktreeId)) { - this.log(`Ignored stale removal for non-stale worktree ${worktreeId}`) - return null - } - - const worktree = state.getWorktree(worktreeId) - if (!worktree) { - this.clearStaleTracking(worktreeId) - this.pushState() - return null - } - - this.naming.forget(worktreeId) - const orphaned = state.removeWorktree(worktreeId) - if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { - this.diffs.stop() - } - for (const session of orphaned) { - this.panel?.sessions.clearSessionDirectory(session.id) - } - this.clearStaleTracking(worktreeId) - this.pushState() - this.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`) - return null + const ctx = this.context + if (!ctx) return null + return removeStaleLifecycleWorktree(ctx, this.lifecycleHost, worktreeId) } /** Promote a session: create a worktree and move the session into it. */ private async onPromoteSession(sessionId: string): Promise { - await this.waitForStateReady("onPromoteSession") - const created = await this.createWorktreeOnDisk({}) - if (!created) return null - - // Run setup script for new worktree (blocks until complete, shows in overlay) - await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id) - - const state = this.getStateManager()! - if (!state.getSession(sessionId)) { - state.addSession(sessionId, created.worktree.id) - } else { - state.moveSession(sessionId, created.worktree.id) - } - - this.registerWorktreeSession(sessionId, created.result.path) - await this.recordPromotionHandoff(sessionId, created.result.path, created.result.branch) - this.notifyWorktreeReady(sessionId, created.result, created.worktree.id) - this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`) - return null - } - - private async recordPromotionHandoff(sessionId: string, dir: string, branch: string): Promise { - try { - await recordPromotionHandoff({ - client: this.connectionService.getClient(), - sessionId, - directory: dir, - branch, - }) - } catch (err) { - this.log("Failed to record worktree promotion handoff:", getErrorMessage(err)) - } + const ctx = this.context + if (!ctx) return null + return promoteLifecycleSession(ctx, this.lifecycleHost, sessionId) } /** Add a new session to an existing worktree. */ private async onAddSessionToWorktree(worktreeId: string, sessionId?: string): Promise { - let client: KiloClient - try { - client = this.connectionService.getClient() - } catch (err) { - this.log("onAddSessionToWorktree: client not available:", err) - this.postToWebview({ type: "error", message: "Not connected to CLI backend" }) - return null - } - - const state = this.getStateManager() - if (!state) return null - - const worktree = state.getWorktree(worktreeId) - if (!worktree) { - this.log(`Worktree ${worktreeId} not found`) - return null - } - - if (sessionId) { - if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId) - else state.addSession(sessionId, worktreeId) - this.registerWorktreeSession(sessionId, worktree.path) - this.pushState() - this.postToWebview({ - type: "agentManager.sessionAdded", - sessionId, - worktreeId, - }) - this.host.capture("Agent Manager Session Started", { - source: PLATFORM, - sessionId, - worktreeId, - existing: true, - }) - this.log(`Added existing session ${sessionId} to worktree ${worktreeId}`) - return null - } - - let session: Session - try { - const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktree.path) - const { data } = await client.session.create( - { directory: worktree.path, platform: PLATFORM, metadata }, - { throwOnError: true }, - ) - session = data - } catch (error) { - const err = getErrorMessage(error) - this.postToWebview({ type: "error", message: `Failed to create session: ${err}` }) - this.host.capture("Agent Manager Session Error", { - source: PLATFORM, - error: err, - context: "addSessionToWorktree", - worktreeId, - }) - return null - } - - state.addSession(session.id, worktreeId) - this.registerWorktreeSession(session.id, worktree.path) - this.pushState() - this.postToWebview({ - type: "agentManager.sessionAdded", - sessionId: session.id, - worktreeId, - }) - - if (this.panel) { - this.panel.sessions.registerSession(session) - } - - this.host.capture("Agent Manager Session Started", { - source: PLATFORM, - sessionId: session.id, - worktreeId, - }) - this.log(`Added session ${session.id} to worktree ${worktreeId}`) - return null + const ctx = this.context + if (!ctx) return null + return addSessionToLifecycleWorktree(ctx, this.lifecycleHost, worktreeId, sessionId) } private onForkSession(sessionId: string, worktreeId?: string, messageId?: string) { @@ -1208,22 +1146,9 @@ export class AgentManagerProvider implements Disposable { /** Stop a session and remove it from Agent Manager. */ private async onCloseSession(sessionId: string): Promise { - const state = this.getStateManager() - const dirs = this.panel?.sessions.getSessionDirectories() - const dir = state?.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd() - await this.panel?.sessions.abortSessions([sessionId]) - this.panelSessions.delete(sessionId) - try { - await stopSessionProcesses(this.connectionService.getClient(), sessionId, dir) - } catch (err) { - this.log("onCloseSession: client not available:", err) - } - - state?.removeSession(sessionId) - this.panel?.sessions.clearSessionDirectory(sessionId) - if (state) this.pushState() - this.log(`Closed session ${sessionId}`) - return null + const ctx = this.context + if (!ctx) return null + return closeLifecycleSession(ctx, this.lifecycleHost, sessionId) } // Multi-version worktree creation @@ -1233,179 +1158,9 @@ export class AgentManagerProvider implements Disposable { msg: Extract, ): Promise { await this.waitForStateReady("onCreateMultiVersion") - const text = msg.text?.trim() || undefined - - const worktreeName = msg.name?.trim() || undefined - const agent = msg.agent - const files = msg.files - const baseBranch = msg.baseBranch - const branchName = msg.branchName?.trim() || undefined - - const fallback = msg.providerID && msg.modelID ? { providerID: msg.providerID, modelID: msg.modelID } : undefined - const resolved = resolveVersionModels(msg.modelAllocations, fallback, Number(msg.versions) || 1) - const { models, versions, providerID, modelID } = resolved - - // Generate a shared group ID for multi-version worktrees - const groupId = versions > 1 ? `grp-${Date.now()}` : undefined - - this.log( - `Creating ${versions} worktrees${models.length > 0 ? " (model comparison)" : ""}${text ? ` for: ${text.slice(0, 60)}` : ""}${groupId ? ` (group=${groupId})` : ""}`, - ) - - // Notify webview that multi-version creation has started - this.postToWebview({ - type: "agentManager.multiVersionProgress", - status: "creating", - total: versions, - completed: 0, - groupId, - }) - - // Phase 1: Create all worktrees + sessions first - const created: CreatedVersion[] = [] - - for (let i = 0; i < versions; i++) { - this.log(`Creating worktree ${i + 1}/${versions}`) - - const version = versionedName(branchName || worktreeName, i, versions) - const wt = await this.createWorktreeOnDisk({ - groupId, - baseBranch, - branchName: version.branch, - name: version.branch, - label: version.label, - }) - if (!wt) { - this.log(`Failed to create worktree for version ${i + 1}`) - continue - } - - await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch, wt.worktree.id) - - const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch, wt.worktree.id) - if (!session) { - const state = this.getStateManager() - const manager = this.getWorktreeManager() - state?.removeWorktree(wt.worktree.id) - await manager?.removeWorktree(wt.result.path) - this.log(`Failed to create session for version ${i + 1}`) - continue - } - - const state = this.getStateManager()! - state.addSession(session.id, wt.worktree.id) - if (!branchName && !worktreeName && this.host.autoBranchNaming().enabled) { - state.armAutoName(wt.worktree.id, session.id) - } - - // Sandbox must match the user's choice before this session is exposed or - // receives its initial prompt. A failed reconciliation aborts this version. - if (msg.sandbox !== undefined) { - try { - await ensureSandbox(this.connectionService.getClient(), session.id, wt.result.path, msg.sandbox) - } catch (error) { - const err = getErrorMessage(error) - this.log(`Failed to configure sandbox for ${session.id}: ${err}`) - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "error", - message: `Failed to configure sandbox: ${err}`, - worktreeId: wt.worktree.id, - }) - this.host.capture("Agent Manager Session Error", { - source: PLATFORM, - error: err, - context: "configureSandbox", - }) - await this.discardWorktree(wt.worktree.id, wt.result.path, wt.result.branch, session.id) - continue - } - } - - this.registerWorktreeSession(session.id, wt.result.path) - this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id) - this.panel?.sessions.registerSession(session) - - // Set the per-version model immediately so the UI selector reflects - // the correct model as soon as the worktree appears, before Phase 2. - // Uses a dedicated message type to avoid clearing the busy state. - const versionModel = models[i] - const earlyProviderID = versionModel?.providerID ?? providerID - const earlyModelID = versionModel?.modelID ?? modelID - if (earlyProviderID && earlyModelID) { - this.postToWebview({ - type: "agentManager.setSessionModel", - sessionId: session.id, - providerID: earlyProviderID, - modelID: earlyModelID, - }) - } - - created.push({ - worktreeId: wt.worktree.id, - sessionId: session.id, - path: wt.result.path, - branch: wt.result.branch, - parentBranch: wt.result.parentBranch, - versionIndex: i, - }) - - this.host.capture("Agent Manager Session Started", { - source: PLATFORM, - sessionId: session.id, - worktreeId: wt.worktree.id, - branch: wt.result.branch, - multiVersion: true, - version: i + 1, - totalVersions: versions, - groupId, - }) - this.log(`Version ${i + 1} worktree ready: session=${session.id}`) - - // Update progress - this.postToWebview({ - type: "agentManager.multiVersionProgress", - status: "creating", - total: versions, - completed: created.length, - groupId, - }) - } - - // Phase 2: Send the initial prompt to all sessions, or clear busy state if no text. - const messages = buildInitialMessages(created, models, { providerID, modelID }, text, agent, msg.variant, files) - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]! - if (text) { - this.log(`Sending initial message to version ${i + 1} (session=${msg.sessionId})`) - this.naming.prompt({ - sessionID: msg.sessionId, - text, - providerID: msg.providerID, - modelID: msg.modelID, - }) - } - this.postToWebview({ type: "agentManager.sendInitialMessage", ...msg }) - if (text && i < messages.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 300)) - } - } - - // Notify completion - this.postToWebview({ - type: "agentManager.multiVersionProgress", - status: "done", - total: versions, - completed: created.length, - groupId, - }) - - if (created.length === 0) { - this.host.showError(`Failed to create any of the ${versions} multi-version worktrees.`) - } - - this.log(`Multi-version creation complete: ${created.length}/${versions} versions`) - return null + const ctx = this.context + if (!ctx) return null + return createMultiVersion(ctx, this.multiVersionHost, msg) } private sendKeybindings(): void { @@ -1441,21 +1196,21 @@ export class AgentManagerProvider implements Disposable { await copyEnvFiles(root, worktreePath, (msg) => this.outputChannel.appendLine(`[EnvCopy] ${msg}`)) try { - const service = this.getSetupScriptService() - if (!service || !service.hasScript()) return - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Running setup script...", - branch, - worktreeId, - }) - const runner = new SetupScriptRunner( - (msg) => this.outputChannel.appendLine(`[SetupScriptRunner] ${msg}`), - service, - executeVscodeTask, + await runWorktreeSetupScript( + { + service: this.getSetupScriptService(), + destination: this.destination.value(), + projectId: this.context?.id, + worktreeId, + branch, + trusted: () => this.host.isTrusted(), + manager: this.scripts.manager, + vscode: executeVscodeTask, + log: (msg) => this.outputChannel.appendLine(`[SetupScript] ${msg}`), + post: (message) => this.postToWebview(message), + }, + { worktreePath, repoPath: root }, ) - await runner.runIfConfigured({ worktreePath, repoPath: root }) } catch (error) { const msg = error instanceof Error ? error.message : String(error) this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`) @@ -1463,6 +1218,7 @@ export class AgentManagerProvider implements Disposable { type: "agentManager.worktreeSetup", status: "error", message: `Setup script failed: ${msg}`, + projectId: this.context?.id, branch, worktreeId, }) @@ -1551,7 +1307,9 @@ export class AgentManagerProvider implements Disposable { const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId)) const staleChanged = next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId)) - this.staleWorktreeIds = next + const stale = this.staleWorktreeIds + stale.clear() + for (const id of next) stale.add(id) if (staleChanged || branchChanged) { this.pushState() @@ -1586,34 +1344,41 @@ export class AgentManagerProvider implements Disposable { } const stats = this.statsPoller.syncSkips(skipped) if (!stats) return - const msg = { type: "agentManager.worktreeStats" as const, stats } + const msg = { type: "agentManager.worktreeStats" as const, projectId: this.contexts.active()?.id, stats } this.cachedWorktreeStats = msg this.postToWebview(msg) } - private pushState(): void { - const state = this.state - if (!state) return + private pushState(ctx?: ProjectContext): void { + const target = ctx ?? this.context + const state = target?.peekState() + if (!target || !state) { + this.log(`pushState skipped: target=${target?.id ?? "none"}, state=${state ? "ok" : "missing"}`) + return + } + const active = this.contexts.active()?.id === target.id const worktrees = state.getWorktrees() - const staleWorktreeIds = this.staleWorktreesForState(worktrees) - const run = this.run.state() this.postToWebview({ type: "agentManager.state", + projectId: target.id, worktrees, sessions: state.getSessions(), sections: state.getSections(), - staleWorktreeIds, + staleWorktreeIds: active ? this.staleWorktreesForState(worktrees) : [], tabOrder: state.getTabOrder(), worktreeOrder: state.getWorktreeOrder(), sessionsCollapsed: state.getSessionsCollapsed(), sidebarCollapsed: state.getSidebarCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), - terminalDestination: readTerminalDestination(), + terminalDestination: this.destination.value(), isGitRepo: true, defaultBaseBranch: state.getDefaultBaseBranch(), - ...run, + activeTarget: state.getActiveTarget(), + ...(active ? this.runStateFor(target) : {}), }) + void pushProjectSessions(target, this.panel?.sessions, (message) => this.postToWebview(message)) + if (!active) return // Sync skip set before enabling the poller so the first poll cycle // already excludes worktrees in collapsed sections. @@ -1632,54 +1397,176 @@ export class AgentManagerProvider implements Disposable { staleWorktreeIds: [], reviewDiffStyle: "unified", reviewMarkdownRender: getDiffMarkdownRender(), - terminalDestination: readTerminalDestination(), + terminalDestination: this.destination.value(), isGitRepo: false, runStatuses: [], runScriptConfigured: false, }) } - // Manager accessors + // Manager accessors — repository-bound services are owned by the active ProjectContext (immutable per root). + /** Provider capabilities for the worktree lifecycle handlers (state stays in ProjectContext). */ + private get lifecycleHost(): LifecycleHost { + return { + createOnDisk: (opts) => this.createWorktreeOnDisk(opts), + runSetup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), + createSession: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id), + notifyReady: (sid, result, id) => this.notifyWorktreeReady(sid, result, id), + sessions: { + register: (session) => this.panel?.sessions.registerSession(session), + clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid), + directories: () => this.panel?.sessions.getSessionDirectories(), + abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(), + forget: (sid) => void this.panelSessions.delete(sid), + }, + push: () => this.pushState(), + register: (sid, dir) => this.registerWorktreeSession(sid, dir), + skipStats: (id) => this.statsPoller.skipWorktree(id), + unskipStats: (id) => this.statsPoller.unskipWorktree(id), + removePR: (id) => this.prBridge.remove(id), + removeRun: (id) => this.run.remove(id), + clearRun: (id) => clearScriptTerminals(this.scripts.manager, id, this.context?.id), + forgetName: (id) => this.naming.forget(id), + stopDiffs: (path, orphaned) => { + if (this.diffs.shouldStopForWorktree(path, orphaned)) this.diffs.stop() + }, + capture: (event, props) => this.host.capture(event, props), + autoName: () => this.host.autoBranchNaming(), + client: () => this.connectionService.getClient(), + metadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir), + post: (msg) => this.postToWebview(msg), + log: (...args) => this.log(...args), + } + } + + private get multiVersionHost(): MultiVersionHost { + return { + ...this.lifecycleHost, + discard: (id, dir, branch, sessionId) => this.discardWorktree(id, dir, branch, sessionId), + promptName: (input) => this.naming.prompt(input), + error: (message) => this.host.showError(message), + } + } + + private get context(): ProjectContext | undefined { + return this.projectScope.current() ?? this.contexts.active() + } private getRoot(): string | undefined { - return this.host.workspacePath() + return this.context?.root } private getWorktreeManager(): WorktreeManager | undefined { - if (this.worktrees) return this.worktrees - const root = this.getRoot() - if (!root) { - this.log("getWorktreeManager: no folder available") - return undefined - } - this.worktrees = new WorktreeManager( - root, - (msg) => this.outputChannel.appendLine(`[WorktreeManager] ${msg}`), - this.gitOps, - ) - return this.worktrees + return this.context?.worktreeManager() } private getStateManager(): WorktreeStateManager | undefined { - if (this.state) return this.state - const root = this.getRoot() - if (!root) { - this.log("getStateManager: no folder available") - return undefined - } - this.state = new WorktreeStateManager(root, (msg) => this.outputChannel.appendLine(`[StateManager] ${msg}`)) - return this.state + return this.context?.stateManager() } private getSetupScriptService(): SetupScriptService | undefined { - if (this.setupScript) return this.setupScript - const root = this.getRoot() - if (!root) { - this.log("getSetupScriptService: no folder available") - return undefined + return this.context?.setupService() + } + + private get state(): WorktreeStateManager | undefined { + return this.context?.peekState() + } + private get worktrees(): WorktreeManager | undefined { + return this.context?.peekWorktrees() + } + + private get setupScript(): SetupScriptService | undefined { + return this.context?.peekSetup() + } + + private get staleWorktreeIds(): Set { + return this.context?.stale ?? this.staleScratch + } + + /** + * Namespace the shared "local" run key with the owning project id so two + * projects' local run scripts do not collide in the provider-wide manager. + */ + private runKey(worktreeId: string): string { + if (worktreeId !== "local") return worktreeId + if (!this.host.multiProject()) return worktreeId + const ctx = this.context + if (!ctx) return worktreeId + return `${ctx.id}:local` + } + + /** Resolve the project bucket that owns a provider-wide script key. */ + private projectForScript(worktreeId: string): string | undefined { + if (worktreeId.endsWith(":local") && worktreeId !== "local") return worktreeId.slice(0, -":local".length) + return this.contexts.byWorktree(worktreeId)?.id ?? this.context?.id + } + + /** Run state for one project's payload: its worktrees and its own local key, un-namespaced. */ + private runStateFor(ctx: ProjectContext): ReturnType { + const state = this.run.state() + const ids = new Set((ctx.peekState()?.getWorktrees() ?? []).map((wt) => wt.id)) + const localKey = `${ctx.id}:local` + const runStatuses = state.runStatuses + .filter( + (status) => + ids.has(status.worktreeId) || status.worktreeId === localKey || (status.worktreeId === "local" && ctx.pinned), + ) + .map((status) => (status.worktreeId === localKey ? { ...status, worktreeId: "local" } : status)) + return { ...state, runStatuses } + } + + /** Route run status emissions to the owning project and un-namespace the local key. */ + private postRunMessage(message: AgentManagerOutMessage): void { + if (message.type !== "agentManager.runStatus") { + this.postToWebview(message) + return } - this.setupScript = new SetupScriptService(root) - return this.setupScript + const qualified = message.worktreeId.endsWith(":local") && message.worktreeId !== "local" + const owner = qualified + ? this.contexts.resolve(message.worktreeId.slice(0, -":local".length)) + : this.contexts.byWorktree(message.worktreeId) + this.postToWebview({ + ...message, + worktreeId: qualified ? "local" : message.worktreeId, + ...(owner ? { projectId: owner.id } : {}), + }) + } + + private messageProject(m: AgentManagerInMessage): ProjectContext | undefined { + const pid = (m as { projectId?: unknown }).projectId + if (typeof pid !== "string") return this.contexts.active() + // Re-check trust and enablement on every project-stamped message: a context + // instance can be cached before trust is confirmed, and get() checks neither. + return this.contexts.usable(pid) + } + + private activateProject(ctx: ProjectContext): void { + if (this.contexts.active()?.id !== ctx.id) return + this.statsPoller.stop() + this.prBridge.reset() + this.activeSessionId = undefined + this.cachedWorktreeStats = this.cachedLocalStats = undefined + void this.sendRepoInfo() + if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) + this.stateReady = this.initializeState() + else this.projectPollers.sync(this.contexts) + } + private onWorkspaceChanged(): void { + if (this.contexts.syncPinned()) { + this.activeSessionId = undefined + this.stateReady = this.initializeState() + void this.sendRepoInfo() + } + this.pushProjects() + } + + private pushProjects(): void { + this.postToWebview({ + type: "agentManager.projects", + multiProject: this.host.multiProject(), + projects: this.contexts.snapshots(), + }) + this.projectPollers.sync(this.contexts) } // Worktree file helpers @@ -1700,18 +1587,19 @@ export class AgentManagerProvider implements Disposable { } /** Open a file from a worktree or local session in the VS Code editor. - * Absolute paths (Unix `/…` or Windows `C:\…`) are opened directly. - * Relative paths are resolved against the session's worktree directory - * (or repo root for local sessions) with symlink-traversal protection. */ - private openWorktreeFile(sessionId: string, filePath: string, line?: number, column?: number): void { + * Absolute paths are opened directly; relative paths resolve against the + * context's worktree directory (repo root for local) with symlink-traversal + * protection. The id may be a worktree id, session id, or `local`. */ + private openWorktreeFile(id: string, filePath: string, line?: number, column?: number): void { if (isAbsolutePath(filePath)) { this.host.openFile(filePath, line, column) return } const state = this.getStateManager() if (!state) return - const session = state.getSession(sessionId) - const base = session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getRoot() + const worktree = state.getWorktree(id) + const session = worktree ? undefined : state.getSession(id) + const base = worktree?.path ?? (session?.worktreeId ? state.getWorktree(session.worktreeId)?.path : this.getRoot()) if (!base) return // Resolve real paths to prevent symlink traversal and normalize for // consistent comparison on both Unix and Windows. @@ -1729,7 +1617,11 @@ export class AgentManagerProvider implements Disposable { } private postToWebview(message: AgentManagerOutMessage): void { - this.panel?.postMessage(message) + if (message.type !== "agentManager.worktreeSetup" || message.projectId) { + this.panel?.postMessage(message) + return + } + this.panel?.postMessage({ ...message, projectId: this.context?.id }) } /** @@ -1820,6 +1712,9 @@ export class AgentManagerProvider implements Disposable { ) } + public workspaceRoot = () => this.getRoot() + + public projectId = () => this.contexts.active()?.id /** * Continue a sidebar session in a new worktree. * Captures git state, creates worktree, applies state, forks session. @@ -1888,7 +1783,12 @@ export class AgentManagerProvider implements Disposable { } private handleSection(m: AgentManagerInMessage): boolean { - return handleSection(this.state, m, () => this.pushState()) + return handleSection( + this.state, + m, + () => this.pushState(), + (...args) => this.log(...args), + ) } public postMessage(message: unknown): void { @@ -1906,16 +1806,21 @@ export class AgentManagerProvider implements Disposable { private async disposeAsync(): Promise { await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err)) - await this.state?.flush().catch((err) => this.log("dispose: state flush failed:", err)) + await this.contexts.dispose() this.unsubTool?.() this.unsubStatus?.() + this.unsubSessions?.() this.unsubFont?.() + this.unsubProjects?.() this.unsubDestination?.() + await this.scripts.dispose() this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() + this.diffCatalog.dispose() this.naming.dispose() this.statsPoller.stop() + this.projectPollers.dispose() this.gitOps.dispose() this.prBridge.poller.stop() this.run.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index ce386a858a..9b5fd45ef5 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -3,7 +3,7 @@ import * as path from "path" import { remoteRef, type Worktree } from "./WorktreeStateManager" import type { GitOps } from "./GitOps" import type { Semaphore } from "./semaphore" -import { normalizePath } from "./git-import" +import { findTrackedBranch } from "./project/paths" import type { WorktreeDiffEntry } from "./types" export interface WorktreeStats { @@ -69,6 +69,7 @@ export class GitStatsPoller { private readonly git: GitOps private skipWorktreeIds = new Set() private visible = true + private generation = 0 constructor(private readonly options: GitStatsPollerOptions) { this.intervalMs = options.intervalMs ?? 5000 @@ -102,6 +103,10 @@ export class GitStatsPoller { this.skipWorktreeIds.add(id) } + unskipWorktree(id: string): void { + this.skipWorktreeIds.delete(id) + } + setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return @@ -113,6 +118,7 @@ export class GitStatsPoller { } stop(): void { + this.generation++ this.active = false if (this.timer) { clearTimeout(this.timer) @@ -153,21 +159,26 @@ export class GitStatsPoller { if (!this.active) return Promise.resolve() if (this.busy) return Promise.resolve() this.busy = true - return this.fetch().finally(() => { + const generation = this.generation + return this.fetch(generation).finally(() => { + // stop() already reset busy and bumped the generation, so a stale + // fetch must not touch busy: a restarted poll may own it right now. + if (generation !== this.generation) return this.busy = false this.schedule(this.currentInterval()) }) } - private async fetch(): Promise { - await Promise.all([this.fetchWorktreeStats(), this.fetchLocalStats()]) + private async fetch(generation = this.generation): Promise { + await Promise.all([this.fetchWorktreeStats(false, generation), this.fetchLocalStats(generation)]) } - private async fetchWorktreeStats(includeSkipped = false): Promise { + private async fetchWorktreeStats(includeSkipped = false, generation = this.generation): Promise { const worktrees = this.options.getWorktrees() if (worktrees.length === 0) return const presence = await this.probeWorktreePresence(worktrees) + if (generation !== this.generation) return this.options.onWorktreePresence?.(presence) const missing = new Set( @@ -211,6 +222,7 @@ export class GitStatsPoller { }), ) ).filter((item): item is WorktreeStats => !!item) + if (generation !== this.generation) return for (const item of stats) this.lastStats[item.worktreeId] = item @@ -248,13 +260,12 @@ export class GitStatsPoller { const worktreeStatuses = await Promise.all( worktrees.map(async (wt) => { const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path) - const normalized = normalizePath(abs) const exists = await fs.promises.access(abs).then( () => true, () => false, ) - const missing = !exists || !tracked.has(normalized) - const branch = tracked.get(normalized) + const branch = exists ? findTrackedBranch(tracked, abs) : undefined + const missing = !exists || branch === undefined return { worktreeId: wt.id, missing, branch } }), ) @@ -262,7 +273,7 @@ export class GitStatsPoller { return { worktrees: worktreeStatuses, degraded: false } } - private async fetchLocalStats(): Promise { + private async fetchLocalStats(generation = this.generation): Promise { const root = this.options.getWorkspaceRoot() if (!root) return @@ -301,6 +312,7 @@ export class GitStatsPoller { if (this.lastLocalStats && this.lastLocalStats.branch === branch) return return } + if (generation !== this.generation) return const hash = `local:${branch}:${files}:${additions}:${deletions}:${ahead}:${behind}` if (hash === this.lastLocalHash) { diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index f655616b96..155bd410bb 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -38,6 +38,11 @@ export class PRStatusPoller { private lastFullSync = 0 // timestamp of last full (all-worktree) sync private readonly intervalMs: number private readonly semaphore: Semaphore | undefined + private generation = 0 + + private stale(generation: number): boolean { + return generation !== this.generation + } constructor(private readonly options: PRStatusPollerOptions) { this.intervalMs = options.intervalMs ?? 15_000 @@ -87,6 +92,7 @@ export class PRStatusPoller { } stop(): void { + this.generation++ this.active = false if (this.timer) { clearTimeout(this.timer) @@ -144,7 +150,11 @@ export class PRStatusPoller { if (!this.active || !this.visible) return Promise.resolve() if (this.busy) return Promise.resolve() this.busy = true - return this.fetchAll().finally(() => { + const generation = this.generation + return this.fetchAll(generation).finally(() => { + // stop() already reset busy and bumped the generation, so a stale + // fetch must not touch busy: a restarted poll may own it right now. + if (this.stale(generation)) return this.busy = false this.schedule() }) @@ -165,8 +175,9 @@ export class PRStatusPoller { return this.ghAvailable } - private async fetchAll(): Promise { + private async fetchAll(generation = this.generation): Promise { if (!(await this.probeGh())) { + if (generation !== this.generation) return // De-duplicate: only emit gh_missing once, not every poll cycle if (this.lastError !== "gh_missing") { this.lastError = "gh_missing" @@ -196,10 +207,11 @@ export class PRStatusPoller { return } - const thunks = targets.map((wt) => () => this.fetchOne(wt.id)) + const thunks = targets.map((wt) => () => this.fetchOne(wt.id, generation)) const results = full ? await settled(thunks, FULL_SYNC_CONCURRENCY) : await Promise.allSettled(thunks.map((fn) => fn())) + if (this.stale(generation)) return const ok = results.every((r) => r.status === "fulfilled") if (ok) { this.failures = 0 @@ -208,16 +220,14 @@ export class PRStatusPoller { this.failures++ } - private async fetchOne(worktreeId: string): Promise { - const worktrees = this.options.getWorktrees() - const wt = worktrees.find((w) => w.id === worktreeId) + private async fetchOne(worktreeId: string, generation = this.generation): Promise { + const wt = this.target(worktreeId) if (!wt) return - if (!this.options.getWorkspaceRoot()) return - try { const pr = await this.cachedFetchPR(wt.branch, wt.path) - if (!pr) { + if (!pr || this.stale(generation)) { + if (this.stale(generation)) return const hash = `${worktreeId}:none` if (this.lastHash.get(worktreeId) === hash) return this.lastHash.set(worktreeId, hash) @@ -229,6 +239,7 @@ export class PRStatusPoller { this.fetchChecks(pr.number, wt.path), this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined, ]) + if (this.stale(generation)) return const status: PRStatus = { number: pr.number, @@ -249,23 +260,28 @@ export class PRStatusPoller { this.options.onStatus(worktreeId, status) } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - const kind = classifyPRError(msg) - this.options.log(`PR fetch failed for ${wt.branch}:`, msg) - - const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed" - if (kind === "gh_missing") this.ghAvailable = false - - // De-duplicate: only emit if the error state changed for this worktree - const hash = `${worktreeId}:error:${errKey}` - if (this.lastHash.get(worktreeId) !== hash) { - this.lastHash.set(worktreeId, hash) - this.options.onStatus(worktreeId, null, errKey) - } + this.handleError(worktreeId, wt.branch, err) throw err // propagate so fetchAll can track failures for backoff } } + private handleError(worktreeId: string, branch: string, err: unknown): void { + const msg = err instanceof Error ? err.message : String(err) + const kind = classifyPRError(msg) + this.options.log(`PR fetch failed for ${branch}:`, msg) + const key = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed" + if (kind === "gh_missing") this.ghAvailable = false + const hash = `${worktreeId}:error:${key}` + if (this.lastHash.get(worktreeId) === hash) return + this.lastHash.set(worktreeId, hash) + this.options.onStatus(worktreeId, null, key) + } + + private target(worktreeId: string): Worktree | undefined { + if (!this.options.getWorkspaceRoot()) return + return this.options.getWorktrees().find((worktree) => worktree.id === worktreeId) + } + private static readonly PR_JSON_FIELDS = "number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" diff --git a/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts new file mode 100644 index 0000000000..b1c438cb12 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts @@ -0,0 +1,460 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { TerminalFont } from "./terminal-font" +import type { RunHandle } from "./run/manager" + +export type ScriptTerminalKind = "run" | "setup" +type ScriptTerminalState = "running" | "stopping" | "exited" | "failed" + +const TITLE: Record = { run: "Run", setup: "Setup" } + +interface ScriptTerminalConfig { + projectId?: string + worktreeId: string + command: string + args: string[] + cwd: string + env: Record +} + +interface ScriptTerminalExit { + exitCode?: number + stopped?: boolean + error?: string +} + +export interface ScriptTerminalView { + terminalId: string + projectId?: string + /** null for the LOCAL workspace; RunController retains its internal "local" key. */ + worktreeId: string | null + kind: ScriptTerminalKind + title: "Run" | "Setup" + wsUrl: string + state: ScriptTerminalState + exitCode?: number + font: TerminalFont +} + +interface ScriptTerminalDeps { + getClient(): KiloClient + getClientAsync(directory: string): Promise + buildWsUrl(ptyID: string, cwd: string): string + getTerminalFont(): TerminalFont + emit(terminals: ScriptTerminalView[]): void + closed(terminalId: string): void + log(msg: string): void +} + +interface Entry { + key: string + kind: ScriptTerminalKind + terminalId: string + ptyID: string + projectId?: string + worktreeId: string + cwd: string + wsUrl: string + state: ScriptTerminalState + exitCode?: number + done: (exit: ScriptTerminalExit) => void + finished: boolean + closing?: Promise +} + +interface TerminalMessage { + type: string + terminalId?: unknown + cols?: unknown + rows?: unknown +} + +function message(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function missing(error: unknown): boolean { + if (!error || typeof error !== "object") return false + const value = error as Record + if (value.status === 404 || value._tag === "PtyNotFoundError") return true + if (!value.data || typeof value.data !== "object") return false + const data = value.data as Record + return data.status === 404 || data._tag === "PtyNotFoundError" +} + +function key(kind: ScriptTerminalKind, worktreeId: string, projectId?: string): string { + return `${projectId ?? "single"}:${kind}:${worktreeId}` +} + +function terminalId(): string { + return `script:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +/** + * Owns extension-host script PTYs independently from webview terminal routing. + * Exited records stay available for output replay until the user closes them. + */ +export class ScriptTerminalManager { + private readonly entries = new Map() + private readonly terminals = new Map() + private readonly ptys = new Map() + + constructor(private readonly deps: ScriptTerminalDeps) {} + + async start( + kind: ScriptTerminalKind, + config: ScriptTerminalConfig, + done: (exit: ScriptTerminalExit) => void, + ): Promise { + const id = key(kind, config.worktreeId, config.projectId) + const prior = this.entries.get(id) + if (prior) { + if (prior.state === "running" || prior.state === "stopping") + throw new Error(`${TITLE[kind]} terminal is already active`) + await this.remove(prior, false) + if (this.entries.has(id)) throw new Error(`Failed to remove previous ${TITLE[kind]} terminal`) + } + + const client = await this.deps.getClientAsync(config.cwd).catch((error) => { + const detail = message(error) + this.deps.log(`${TITLE[kind]} terminal create failed: ${detail}`) + throw new Error(detail) + }) + const created = await client.v2.pty + .create({ + location: { directory: config.cwd }, + command: config.command, + args: config.args, + cwd: config.cwd, + env: config.env, + title: TITLE[kind], + }) + .catch((error) => { + const detail = message(error) + this.deps.log(`${TITLE[kind]} terminal create failed: ${detail}`) + throw new Error(detail) + }) + const pty = created.data?.data + if (created.error || !pty) { + const detail = message(created.error ?? "unknown error") + this.deps.log(`${TITLE[kind]} terminal create failed: ${detail}`) + throw new Error(`Failed to create ${TITLE[kind]} terminal: ${detail}`) + } + + const wsUrl = await this.url(client, kind, pty.id, config.cwd) + const entry: Entry = { + key: id, + kind, + terminalId: terminalId(), + ptyID: pty.id, + projectId: config.projectId, + worktreeId: config.worktreeId, + cwd: config.cwd, + wsUrl, + state: "running", + done, + finished: false, + } + this.entries.set(entry.key, entry) + this.terminals.set(entry.terminalId, entry) + this.ptys.set(entry.ptyID, entry) + this.emit() + + await this.reconcile(entry, client) + + return { + stop: () => this.stop(entry), + kill: (reason) => this.kill(entry, reason), + } + } + + /** Return true only for close/resize messages owned by a script terminal. */ + intercept(msg: TerminalMessage): boolean { + const id = msg.terminalId + if (typeof id !== "string" || !this.terminals.has(id)) return false + if (msg.type === "agentManager.terminal.close") { + void this.close(id).then((closed) => { + if (closed) this.deps.closed(id) + }) + return true + } + if (msg.type === "agentManager.terminal.stop") { + // Deliberate user stop: always allowed, even for a running Setup + // script whose accidental close is blocked. + void this.close(id, true).then((closed) => { + if (closed) this.deps.closed(id) + }) + return true + } + if (msg.type !== "agentManager.terminal.resize") return false + if (typeof msg.cols !== "number" || typeof msg.rows !== "number") return true + void this.resize(id, msg.cols, msg.rows) + return true + } + + exited(ptyID: string, exitCode: number): void { + const entry = this.ptys.get(ptyID) + if (!entry) return + this.finishExited(entry, exitCode) + } + + deleted(ptyID: string): void { + const entry = this.ptys.get(ptyID) + if (!entry) return + const state = entry.state + if (state === "failed") { + // Retained failure (e.g. after a timeout kill): the backend PTY is + // gone now, but the tab and its output stay until the user closes it. + this.ptys.delete(ptyID) + return + } + this.drop(entry) + this.emit() + if (state === "stopping") { + this.done(entry, { stopped: true }) + return + } + if (state === "running") this.done(entry, { error: `${TITLE[entry.kind]} terminal was removed before it exited` }) + } + + snapshot(): void { + this.emit() + } + + owns(ptyID: string): boolean { + return this.ptys.has(ptyID) + } + + /** True while a script of this kind is running or stopping for the worktree. */ + active(kind: ScriptTerminalKind, worktreeId: string, projectId?: string): boolean { + const entry = this.entries.get(key(kind, worktreeId, projectId)) + return entry?.state === "running" || entry?.state === "stopping" + } + + async sync(): Promise { + await Promise.all( + [...this.entries.values()].map(async (entry) => { + const client = await this.deps.getClientAsync(entry.cwd).catch((error) => { + this.deps.log(`Failed to reconnect ${TITLE[entry.kind]} terminal: ${message(error)}`) + return undefined + }) + if (client) await this.reconcile(entry, client) + }), + ) + } + + async clear(kind: ScriptTerminalKind, worktreeId: string, projectId?: string): Promise { + const entry = this.entries.get(key(kind, worktreeId, projectId)) + if (!entry) return true + return this.close(entry.terminalId, true) + } + + /** + * User-initiated tab close. A running Setup script must keep its output + * and finish (or time out) on its own, so only forced paths (worktree + * deletion, shutdown, timeout) may tear it down early. + */ + async close(terminalId: string, force = false): Promise { + const entry = this.terminals.get(terminalId) + if (!entry) return true + if (!force && entry.kind === "setup" && (entry.state === "running" || entry.state === "stopping")) { + this.deps.log(`Ignored close for ${TITLE[entry.kind]} terminal while it is running`) + return false + } + if (entry.state === "running") { + await this.stop(entry) + return !this.terminals.has(terminalId) + } + if (entry.state === "stopping") { + await entry.closing + return !this.terminals.has(terminalId) + } + await this.remove(entry, false) + return !this.terminals.has(terminalId) + } + + async resize(terminalId: string, cols: number, rows: number): Promise { + const entry = this.terminals.get(terminalId) + if (!entry) return + try { + const client = this.deps.getClient() + const result = await client.v2.pty.update({ + ptyID: entry.ptyID, + location: { directory: entry.cwd }, + size: { cols, rows }, + }) + if (!result.error) return + this.deps.log(`${TITLE[entry.kind]} terminal resize failed (${terminalId}): ${message(result.error)}`) + } catch (error) { + this.deps.log(`${TITLE[entry.kind]} terminal resize failed (${terminalId}): ${message(error)}`) + } + } + + async dispose(): Promise { + await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId, true))) + } + + private async reconcile(entry: Entry, client: KiloClient): Promise { + if (!this.current(entry)) return + try { + const result = await client.v2.pty.get({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + const pty = result.data?.data + if (result.error || !pty) { + this.missing( + entry, + `${TITLE[entry.kind]} terminal is no longer available: ${message(result.error ?? "unknown error")}`, + ) + return + } + if (pty.status === "exited") this.finishExited(entry, pty.exitCode ?? 0) + } catch (error) { + this.deps.log(`Failed to read ${TITLE[entry.kind]} terminal: ${message(error)}`) + } + } + + private async stop(entry: Entry): Promise { + if (!this.current(entry)) return + if (entry.state === "stopping") { + await entry.closing + return + } + if (entry.state === "exited" || entry.state === "failed") { + await this.remove(entry, false) + return + } + entry.state = "stopping" + this.emit() + await this.remove(entry, true) + } + + private remove(entry: Entry, stopped: boolean): Promise { + if (entry.closing) return entry.closing + const task = this.removeEntry(entry, stopped) + entry.closing = task + void task.finally(() => { + if (this.current(entry) && entry.closing === task) entry.closing = undefined + }) + return task + } + + private async removeEntry(entry: Entry, stopped: boolean): Promise { + try { + const client = await this.deps.getClientAsync(entry.cwd) + const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + if (result.error) { + if (missing(result.error)) { + this.drop(entry) + this.emit() + if (stopped) this.done(entry, { stopped: true }) + return + } + this.failed(entry, `Failed to remove ${TITLE[entry.kind]} terminal: ${message(result.error)}`) + return + } + this.drop(entry) + this.emit() + if (stopped) this.done(entry, { stopped: true }) + } catch (error) { + this.failed(entry, `Failed to remove ${TITLE[entry.kind]} terminal: ${message(error)}`) + } + } + + private async url(client: KiloClient, kind: ScriptTerminalKind, ptyID: string, cwd: string): Promise { + try { + return this.deps.buildWsUrl(ptyID, cwd) + } catch (error) { + this.deps.log(`Failed to build ${TITLE[kind]} terminal URL: ${message(error)}`) + try { + const result = await client.v2.pty.remove({ ptyID, location: { directory: cwd } }) + if (result.error) + this.deps.log(`Failed to remove ${TITLE[kind]} terminal after URL failure: ${message(result.error)}`) + } catch (cleanup) { + this.deps.log(`Failed to remove ${TITLE[kind]} terminal after URL failure: ${message(cleanup)}`) + } + throw error + } + } + + private finishExited(entry: Entry, exitCode: number): void { + if (!this.current(entry) || entry.state === "exited") return + entry.state = "exited" + entry.exitCode = exitCode + this.emit() + this.done(entry, { exitCode }) + } + + private failed(entry: Entry, error: string): void { + if (!this.current(entry)) return + this.deps.log(error) + entry.state = "failed" + this.emit() + this.done(entry, { error }) + } + + /** + * Kill the process tree but retain the terminal as failed with its + * partial output. Timeouts use this so the user can see how far the + * script got; user-initiated stops use stop() and drop the tab instead. + */ + private kill(entry: Entry, reason: string): void { + if (!this.current(entry) || (entry.state !== "running" && entry.state !== "stopping")) return + entry.state = "failed" + this.emit() + void this.deps + .getClientAsync(entry.cwd) + .then(async (client) => { + const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + if (result.error) this.deps.log(`Failed to kill ${TITLE[entry.kind]} terminal: ${message(result.error)}`) + }) + .catch((error) => { + this.deps.log(`Failed to kill ${TITLE[entry.kind]} terminal: ${message(error)}`) + }) + this.deps.log(reason) + this.done(entry, { error: reason }) + } + + private missing(entry: Entry, error: string): void { + if (!this.current(entry)) return + this.deps.log(error) + this.drop(entry) + this.emit() + this.done(entry, { error }) + } + + private done(entry: Entry, exit: ScriptTerminalExit): void { + if (entry.finished) return + entry.finished = true + entry.done(exit) + } + + private drop(entry: Entry): void { + if (!this.current(entry)) return + this.entries.delete(entry.key) + this.terminals.delete(entry.terminalId) + this.ptys.delete(entry.ptyID) + } + + private current(entry: Entry): boolean { + return this.entries.get(entry.key) === entry + } + + private emit(): void { + const terminals: ScriptTerminalView[] = [] + for (const entry of this.entries.values()) { + const local = entry.worktreeId === "local" || entry.worktreeId.endsWith(":local") + const terminal: ScriptTerminalView = { + terminalId: entry.terminalId, + ...(entry.projectId ? { projectId: entry.projectId } : {}), + worktreeId: local ? null : entry.worktreeId, + kind: entry.kind, + title: TITLE[entry.kind], + wsUrl: entry.wsUrl, + state: entry.state, + font: this.deps.getTerminalFont(), + } + if (entry.exitCode !== undefined) terminal.exitCode = entry.exitCode + terminals.push(terminal) + } + this.deps.emit(terminals) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts index 6120ba6a1c..3fc41c1170 100644 --- a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -34,6 +34,8 @@ export interface Disposable { */ export class SessionTerminalManager { private static readonly LOCAL_KEY = "__local__" + private static readonly SESSION_PREFIX = "session:" + private static readonly WORKTREE_PREFIX = "worktree:" private terminals = new Map() private disposables: Disposable[] = [] @@ -47,10 +49,10 @@ export class SessionTerminalManager { ) { this.disposables.push( host.onTerminalClosed((terminal) => { - for (const [sessionId, entry] of this.terminals) { + for (const [key, entry] of this.terminals) { if (entry.terminal !== terminal) continue - this.terminals.delete(sessionId) - this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`) + this.terminals.delete(key) + this.log(`Removed terminal mapping for ${key} (terminal closed)`) break } this.updateContextKey() @@ -86,7 +88,8 @@ export class SessionTerminalManager { */ showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void { // If terminal already exists, just focus it - if (this.showExisting(sessionId, false)) return + const key = SessionTerminalManager.sessionKey(sessionId) + if (this.showExistingKey(key, false)) return const repoPath = this.host.repoPath() const worktreePath = state?.directoryFor(sessionId) @@ -102,7 +105,7 @@ export class SessionTerminalManager { const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local" - this.showOrCreate(sessionId, cwd, name) + this.showOrCreate(key, cwd, name) } /** @@ -110,7 +113,7 @@ export class SessionTerminalManager { * Used when the user triggers a terminal in local mode without an active session. */ showLocalTerminal(): void { - if (this.showExisting(SessionTerminalManager.LOCAL_KEY, false)) return + if (this.showExistingKey(SessionTerminalManager.LOCAL_KEY, false)) return const cwd = this.host.repoPath() if (!cwd) { @@ -122,11 +125,32 @@ export class SessionTerminalManager { this.showOrCreate(SessionTerminalManager.LOCAL_KEY, cwd, "Agent: local") } + /** + * Show (or create) a terminal rooted at a worktree directory. Used when + * the worktree has no session to key the terminal off (e.g. all of its + * sessions were closed) so the shortcut never dead-ends on a sessionless + * worktree. + */ + showWorktreeTerminal(worktreeId: string, state: WorktreeStateManager | undefined): void { + const key = SessionTerminalManager.worktreeKey(worktreeId) + if (this.showExistingKey(key, false)) return + + const worktree = state?.getWorktree(worktreeId) + const cwd = worktree?.path ?? this.host.repoPath() + if (!cwd) { + this.log(`showWorktreeTerminal: no cwd resolved for worktree ${worktreeId}`) + this.host.showWarning("Open a folder that contains a git repository to use worktrees") + return + } + + this.showOrCreate(key, cwd, worktree ? `Agent: ${worktree.branch}` : "Agent: worktree") + } + /** * Show the existing local terminal if one was previously created (used on context switch). */ showExistingLocal(): boolean { - return this.showExisting(SessionTerminalManager.LOCAL_KEY) + return this.showExistingKey(SessionTerminalManager.LOCAL_KEY) } /** @@ -159,34 +183,38 @@ export class SessionTerminalManager { * Pass preserveFocus=true to keep focus on the current editor (default for session switching). */ showExisting(sessionId: string, preserveFocus = true): boolean { - const entry = this.terminals.get(sessionId) + return this.showExistingKey(SessionTerminalManager.sessionKey(sessionId), preserveFocus) + } + + private showExistingKey(key: string, preserveFocus = true): boolean { + const entry = this.terminals.get(key) if (!entry) return false if (entry.terminal.exitStatus !== undefined) { - this.terminals.delete(sessionId) - this.log(`showExisting: terminal exited for session ${sessionId}, clearing`) + this.terminals.delete(key) + this.log(`showExisting: terminal exited for ${key}, clearing`) return false } entry.terminal.show(preserveFocus) this.panelOpen = true - this.log(`showExisting: revealed terminal for session ${sessionId}`) + this.log(`showExisting: revealed terminal for ${key}`) return true } - activeSession(): string | undefined { + private activeKey(): string | undefined { const active = this.host.activeTerminal() if (!active) return undefined - for (const [id, entry] of this.terminals) { - if (entry.terminal === active && entry.terminal.exitStatus === undefined) return id + for (const [key, entry] of this.terminals) { + if (entry.terminal === active && entry.terminal.exitStatus === undefined) return key } return undefined } prepareContext(sessionId: string): boolean { if (this.showExisting(sessionId)) return true - const active = this.activeSession() - return !active || active === sessionId + const active = this.activeKey() + return !active || active === SessionTerminalManager.sessionKey(sessionId) } dispose(): void { @@ -254,32 +282,40 @@ export class SessionTerminalManager { void this.host.setContext("kilo-code.agentTerminalFocus", managed) } - private showOrCreate(sessionId: string, cwd: string, name: string): void { - let entry = this.terminals.get(sessionId) + private showOrCreate(key: string, cwd: string, name: string): void { + let entry = this.terminals.get(key) // Clean up exited terminals if (entry && entry.terminal.exitStatus !== undefined) { - this.terminals.delete(sessionId) + this.terminals.delete(key) entry = undefined } // Recreate if CWD changed if (entry && entry.cwd !== cwd) { entry.terminal.dispose() - this.terminals.delete(sessionId) + this.terminals.delete(key) entry = undefined - this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`) + this.log(`showTerminal: cwd changed for ${key}, recreating`) } if (!entry) { const terminal = this.host.createTerminal({ cwd, name }) entry = { terminal, cwd } - this.terminals.set(sessionId, entry) - this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`) + this.terminals.set(key, entry) + this.log(`showTerminal: created terminal for ${key} (cwd=${cwd})`) } entry.terminal.show(false) this.panelOpen = true this.updateContextKey() } + + private static sessionKey(id: string): string { + return `${SessionTerminalManager.SESSION_PREFIX}${id}` + } + + private static worktreeKey(id: string): string { + return `${SessionTerminalManager.WORKTREE_PREFIX}${id}` + } } diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts index 7aca15168c..6aa46ff789 100644 --- a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -52,6 +52,7 @@ export class SetupScriptRunner { private readonly log: (msg: string) => void, private readonly service: SetupScriptService, private readonly run: RunTask, + private readonly failed: (message: string) => void = () => undefined, ) {} /** @@ -92,6 +93,7 @@ export class SetupScriptRunner { } catch (error) { const msg = error instanceof Error ? error.message : String(error) this.log(`Setup script execution failed: ${msg}`) + this.failed(msg) return true // Script was attempted } } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index cca28e5c3e..d924129626 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -12,6 +12,22 @@ import * as path from "path" import * as fs from "fs" import { normalizePath } from "./git-import" +import type { SidebarTarget } from "./project/route" + +/** Accept a persisted sidebar target only when its shape matches a known kind. */ +function validTarget(value: unknown): SidebarTarget | undefined { + if (!value || typeof value !== "object") return undefined + const target = value as Record + if (typeof target.projectId !== "string") return undefined + if (target.kind === "local") return { projectId: target.projectId, kind: "local" } + if (target.kind === "worktree" && typeof target.worktreeId === "string") { + return { projectId: target.projectId, kind: "worktree", worktreeId: target.worktreeId } + } + if (target.kind === "session" && typeof target.sessionId === "string") { + return { projectId: target.projectId, kind: "session", sessionId: target.sessionId } + } + return undefined +} export interface Worktree { id: string @@ -79,6 +95,7 @@ interface StateFile { sidebarCollapsed?: boolean reviewDiffStyle?: "unified" | "split" defaultBaseBranch?: string + activeTarget?: SidebarTarget } export type StateLoadStatus = "loaded" | "missing" | "failed" @@ -108,6 +125,7 @@ export class WorktreeStateManager { private sidebar = false private reviewDiffStyle: "unified" | "split" = "unified" private defaultBase: string | undefined + private activeTarget: SidebarTarget | undefined private readonly log: (msg: string) => void private saving: Promise | undefined private dirty = false @@ -385,6 +403,23 @@ export class WorktreeStateManager { void this.save() } + /** Last selected sidebar target (Local/worktree/session) for seamless restore. */ + getActiveTarget(): SidebarTarget | undefined { + return this.activeTarget + } + + setActiveTarget(target: SidebarTarget | undefined): void { + const cur = this.activeTarget + const same = + cur?.kind === target?.kind && + cur?.projectId === target?.projectId && + (cur?.kind !== "worktree" || target?.kind !== "worktree" || cur.worktreeId === target.worktreeId) && + (cur?.kind !== "session" || target?.kind !== "session" || cur.sessionId === target.sessionId) + if (same) return + this.activeTarget = target + void this.save() + } + // --------------------------------------------------------------------------- // Worktree order // --------------------------------------------------------------------------- @@ -719,6 +754,7 @@ export class WorktreeStateManager { this.reviewDiffStyle = "split" } this.defaultBase = data.defaultBaseBranch + this.activeTarget = validTarget(data.activeTarget) this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`) if (pruned > 0 || repaired) { if (pruned > 0) this.log(`Pruned ${pruned} orphaned sessions`) @@ -827,6 +863,9 @@ export class WorktreeStateManager { if (this.defaultBase) { data.defaultBaseBranch = this.defaultBase } + if (this.activeTarget) { + data.activeTarget = this.activeTarget + } const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` try { diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 045a108faf..92c6c1a661 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -39,6 +39,7 @@ vi.mock("../SessionTerminalManager", () => ({ SessionTerminalManager: class { showTerminal() {} showLocalTerminal() {} + showWorktreeTerminal() {} syncLocalOnSessionSwitch() {} syncOnSessionSwitch() { return false @@ -70,6 +71,7 @@ function createMockHost(): Host { return { openPanel: vi.fn(), workspacePath: () => "/repo", + isTrusted: () => true, autoBranchNaming: () => ({ enabled: true, prefix: "" }), showError: vi.fn(), openDocument: vi.fn().mockResolvedValue(undefined), @@ -77,7 +79,10 @@ function createMockHost(): Host { openFolder: vi.fn(), createOutput: () => ({ appendLine: vi.fn(), dispose: vi.fn() }) as OutputHandle, extensionKeybindings: () => [], + copyToClipboard: vi.fn(), capture: vi.fn(), + openExternal: vi.fn(), + refreshGit: vi.fn(), dispose: vi.fn(), } } @@ -101,6 +106,7 @@ function createHarness() { prBridge: { handleMessage: ReturnType } activeSessionId: string | undefined naming: { prompt: ReturnType } + scripts: { intercept: ReturnType; snapshot: ReturnType } terminalRouter: { handle: ReturnType } stateReady: Promise | undefined contextTarget: ReturnType @@ -124,6 +130,7 @@ function createHarness() { manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) } manager.activeSessionId = undefined manager.naming = { prompt: vi.fn() } + manager.scripts = { intercept: vi.fn().mockReturnValue(false), snapshot: vi.fn() } manager.terminalRouter = { handle: vi.fn().mockReturnValue(false) } manager.stateReady = Promise.resolve() manager.contextTarget = vi.fn() @@ -216,6 +223,20 @@ describe("AgentManagerProvider worktree creation", () => { expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1) }) + it("disposes orphaned terminals when a freshly mounted webview requests state", async () => { + const manager = createHarness() + const dispose = vi.fn().mockResolvedValue(undefined) + manager.terminalRouter = { handle: vi.fn().mockReturnValue(false), dispose } as unknown as { + handle: ReturnType + } + // Avoid the vscode-backed pushEmptyState; the disposal under test is synchronous. + ;(manager as unknown as Record).pushEmptyState = vi.fn() + + await manager.onMessage({ type: "agentManager.requestState" }) + + expect(dispose).toHaveBeenCalledTimes(1) + }) + it("routes file search through the active worktree session", async () => { const manager = createHarness() manager.activeSessionId = "session-wt" diff --git a/packages/kilo-vscode/src/agent-manager/delete-worktree.ts b/packages/kilo-vscode/src/agent-manager/delete-worktree.ts index ac144290fc..d007d2b208 100644 --- a/packages/kilo-vscode/src/agent-manager/delete-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/delete-worktree.ts @@ -5,16 +5,16 @@ import type { ManagedSession } from "./WorktreeStateManager" * Determine whether diff polling should stop when a worktree is being removed. * * Returns true when the worktree being deleted is currently the diff target - * (either by directory path or because one of its orphaned sessions is the - * active diff session). + * (either by directory path or because the diff context is the worktree + * itself or one of its orphaned sessions). */ export function shouldStopDiffPolling( worktreePath: string, orphaned: ManagedSession[], diffTarget: { directory: string } | undefined, - diffSessionId: string | undefined, + diffCtx: string | undefined, ): boolean { if (diffTarget && normalizePath(diffTarget.directory) === normalizePath(worktreePath)) return true - if (diffSessionId && orphaned.some((s) => s.id === diffSessionId)) return true + if (diffCtx && orphaned.some((s) => s.worktreeId === diffCtx || s.id === diffCtx)) return true return false } diff --git a/packages/kilo-vscode/src/agent-manager/diff-scope.ts b/packages/kilo-vscode/src/agent-manager/diff-scope.ts new file mode 100644 index 0000000000..ff8a5a3bd6 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/diff-scope.ts @@ -0,0 +1,66 @@ +/** + * Composite diff-source keying for Agent Manager. + * + * Agent Manager keys diff sources by *context* (a worktree id, or the `local` + * workspace pseudo-context) while the standalone Changes viewer keys by + * *scope* (branch / staged / unstaged / session). To expose scopes in Agent + * Manager we compose the two into a single id the SourceController can build. + * The context is the sidebar selection, so it stays stable when the user + * switches between session tabs of the same worktree; only the Session scope + * follows the active session, carried inside the id. + * + * ctx = "local" | "" + * scope = "branch" | "staged" | "unstaged" | "session" + * id = `${ctx}#${scope}` (git scopes) + * id = `${ctx}#session:` (session scope, sid = active session id) + * + * `ctx#branch` is the default and reproduces the pre-scope behavior exactly. + */ + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" +const SESSION_TOKEN = "session:" + +export function composeDiffId(ctx: string, scope: DiffScope, sessionId?: string): string { + if (scope === "session" && sessionId) return `${ctx}${SEP}${SESSION_TOKEN}${sessionId}` + return `${ctx}${SEP}${scope}` +} + +/** + * Split a composite id back into context and scope. Tolerates a bare context + * id (no separator) by assuming the default branch scope, which keeps the + * pre-scope messages working unchanged. + */ +export function parseDiffId(id: string): { ctx: string; scope: DiffScope; sessionId?: string } { + const idx = id.lastIndexOf(SEP) + if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE } + const token = id.slice(idx + SEP.length) + const ctx = id.slice(0, idx) + if (token.startsWith(SESSION_TOKEN)) return { ctx, scope: "session", sessionId: token.slice(SESSION_TOKEN.length) } + if (isDiffScope(token)) return { ctx, scope: token } + return { ctx: id, scope: DEFAULT_DIFF_SCOPE } +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +export function normalizeScope(value: unknown): DiffScope { + return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE +} + +/** + * Map a scope to the underlying standalone-viewer source id the catalog knows + * how to build. `branch` maps to the workspace source; `session` needs the + * active session id embedded in the source id (the context id is a worktree + * or `local`, not a session). + */ +export function scopeToSourceId(scope: DiffScope, ctx: string, sessionId?: string): string { + if (scope === "staged") return "staged" + if (scope === "unstaged") return "unstaged" + if (scope === "session") return `session:${sessionId ?? ctx}` + return "workspace" +} diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index ed8b1f2f63..f8e8cee3a6 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -9,6 +9,7 @@ */ import type { Session } from "@kilocode/sdk/v2/client" +import type { ProjectRef, SessionRef, WorktreeRef } from "./project/route" // --------------------------------------------------------------------------- // Primitives @@ -36,6 +37,8 @@ export interface SessionProvider { clearSessionDirectory(id: string): void getSessionDirectories(): ReadonlyMap getSessionInfo?(id: string): Promise + /** List root sessions (no parent) whose directory exactly matches `dir`. */ + listSessions?(dir: string): Promise trackSession(id: string): void refreshSessions(): void registerSession(session: Session): void @@ -49,6 +52,20 @@ export interface SessionProvider { abortSessions(ids: readonly string[]): Promise showMemory(sessionID?: string): Promise toggleMemory(sessionID?: string): Promise + /** Register a project root with the shared route service. */ + registerProjectRoute?(ref: ProjectRef, root: string, generation: number): void + /** Drop a project and all its session/worktree routes. */ + unregisterProjectRoute?(projectId: string): void + /** Register a worktree directory under a project. */ + registerWorktreeRoute?(ref: WorktreeRef, directory: string, generation: number): void + /** Register a session directory under a project (exact routing). */ + registerSessionRoute?(ref: SessionRef, directory: string, generation: number): void + /** Drop one session route (keeps the raw ambiguity index consistent). */ + unregisterSessionRoute?(ref: SessionRef): void + /** Whether a raw session id is known to be ambiguous across projects. */ + isSessionRouteAmbiguous?(sessionId: string): boolean + /** Exact directory for a project-qualified session ref, or undefined. */ + routeSessionDirectoryFor?(ref: SessionRef): string | undefined dispose(): void } @@ -99,11 +116,36 @@ export interface Host { openPanel(opts: { onBeforeMessage: (msg: Record) => Promise | null> worktreeDirectories?: () => string[] + /** Dynamic root directory for the panel's session provider (follows the active project). */ + workspaceRoot?: () => string | undefined + projectId?: () => string | undefined }): PanelContext /** Get the workspace/project root path. */ workspacePath(): string | undefined + /** Show a folder picker and return the selected path, or undefined when cancelled. */ + pickFolder(): Promise + + /** Whether the experimental multi-project Agent Manager mode is enabled. */ + multiProject(): boolean + + /** Read the persisted additional-project registry payload. */ + readProjects(): unknown + + /** Persist the additional-project registry payload. */ + writeProjects(value: unknown): Promise + + unregisterProjectRoutes(projectId: string): void + + /** Subscribe to workspace folder changes (pinned project re-derivation). */ + onDidChangeWorkspaceFolders(cb: () => void): Disposable + + /** Subscribe to multi-project flag changes. */ + onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable + /** Whether the workspace permits executing configured scripts. */ + isTrusted(): boolean + /** Read the user's automatic branch naming preferences. */ autoBranchNaming(): { enabled: boolean; prefix: string } diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index 5675c31c85..cbbb1bffda 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -89,6 +89,11 @@ export class PRStatusBridge { remove(worktreeId: string): void { this.cache.delete(worktreeId) } + + reset(): void { + this.poller.stop() + this.cache.clear() + } } /** Build PRStatusPoller options that forward events through the bridge cache. */ diff --git a/packages/kilo-vscode/src/agent-manager/project/context.ts b/packages/kilo-vscode/src/agent-manager/project/context.ts new file mode 100644 index 0000000000..8dcbb85672 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/context.ts @@ -0,0 +1,230 @@ +/** + * ProjectContext — immutable repository scope for Agent Manager. + * + * A context owns the repository-bound services that used to be singletons on + * the provider: WorktreeStateManager, WorktreeManager, SetupScriptService, + * plus stale-worktree tracking. Services are created lazily on first access + * and never re-pointed at another root, so a context can never mix state + * between repositories. + * + * Lifecycle policy: + * - The pinned context is derived from the current VS Code workspace root and + * always exists while a folder is open. It is implicit and never persisted. + * - Additional projects come from the ProjectRegistry. Their contexts are + * created on demand (expand/select), never eagerly at panel open. + * - Only the active context gets full Git/PR polling; the pollers follow the + * active context through the provider's accessors. + * - Non-pinned contexts require the multi-project flag and registry trust + * before they can be expanded or activated. Trust is checked here, before + * any state load that could write git metadata. + */ + +import * as fs from "fs" +import { WorktreeStateManager } from "../WorktreeStateManager" +import { WorktreeManager } from "../WorktreeManager" +import { SetupScriptService } from "../SetupScriptService" +import type { GitOps } from "../GitOps" +import type { ProjectSessionView } from "./session-view" + +export interface ProjectContextDeps { + log: (msg: string) => void + git?: GitOps + exists?: (dir: string) => boolean + /** Factory overrides for tests. */ + state?: (root: string, log: (msg: string) => void) => WorktreeStateManager + worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager + setup?: (root: string) => SetupScriptService +} + +export type ProjectLifecycle = "cold" | "initializing" | "ready" | "suspended" | "disposing" | "disposed" + +export interface ProjectInitResult { + ok: boolean + refsFixed: number + current: boolean +} + +export class ProjectContext { + private state: WorktreeStateManager | undefined + private worktrees: WorktreeManager | undefined + private setup: SetupScriptService | undefined + private init: Promise | undefined + private last: ProjectInitResult | undefined + private phase: ProjectLifecycle = "cold" + private version = 0 + private mutation: Promise = Promise.resolve() + private live = new Set() + private listed = 0 + private views: readonly ProjectSessionView[] = [] + readonly stale = new Set() + + constructor( + readonly id: string, + readonly root: string, + readonly pinned: boolean, + private readonly deps: ProjectContextDeps, + ) {} + + get lifecycle(): ProjectLifecycle { + return this.phase + } + + get generation(): number { + return this.version + } + + isCurrent(generation: number): boolean { + return this.version === generation && this.phase !== "disposing" && this.phase !== "disposed" + } + + /** Initialize repository state exactly once per context lifetime. */ + ensureReady(run: (generation: number) => Promise>): Promise { + if (this.phase === "disposed" || this.phase === "disposing") { + return Promise.resolve({ ok: false, refsFixed: 0, current: false }) + } + if (this.phase === "ready" && this.last) return Promise.resolve(this.last) + if (this.phase === "suspended" && this.last) { + this.phase = "ready" + return Promise.resolve(this.last) + } + if (this.init) return this.init + const generation = this.version + this.phase = "initializing" + this.init = run(generation) + .then((result) => { + const current = this.isCurrent(generation) + const next = { ...result, current } + if (current) { + this.last = next + this.phase = result.ok ? "ready" : "cold" + } + return next + }) + .finally(() => { + this.init = undefined + }) + return this.init + } + + /** Invalidate asynchronous work while keeping loaded repository state reusable. */ + suspend(): void { + if (this.phase === "disposed" || this.phase === "disposing") return + this.version++ + this.phase = "suspended" + } + + /** Serialize repository mutations and pin them to this context generation. */ + run(operation: (generation: number) => Promise): Promise { + const generation = this.version + const run = async () => { + if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} is no longer available.`) + const result = await operation(generation) + if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} changed while the operation was running.`) + return result + } + const next = this.mutation.then(run, run) + this.mutation = next.catch(() => undefined) + return next + } + + stateManager(): WorktreeStateManager { + this.state ??= (this.deps.state ?? ((root, log) => new WorktreeStateManager(root, log)))(this.root, (msg) => + this.deps.log(`[StateManager] ${msg}`), + ) + return this.state + } + + worktreeManager(): WorktreeManager { + this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))( + this.root, + (msg) => this.deps.log(`[WorktreeManager] ${msg}`), + this.deps.git, + ) + return this.worktrees + } + + setupService(): SetupScriptService { + this.setup ??= (this.deps.setup ?? ((root) => new SetupScriptService(root)))(this.root) + return this.setup + } + + /** Whether repository-bound services have been created. */ + get loaded(): boolean { + return this.state !== undefined + } + + /** Accessors that never create services, preserving "created?" checks. */ + peekState(): WorktreeStateManager | undefined { + return this.state + } + + peekWorktrees(): WorktreeManager | undefined { + return this.worktrees + } + + peekSetup(): SetupScriptService | undefined { + return this.setup + } + + setLiveSessions(ids: Iterable): void { + this.live = new Set(ids) + } + + hasLiveSession(id: string): boolean { + return this.live.has(id) + } + + /** Replace the cached sidebar session list and mark it freshly listed. */ + setSessions(views: readonly ProjectSessionView[]): void { + this.views = views + this.live = new Set(views.map((view) => view.id)) + this.listed = Date.now() + } + + /** The last collected sidebar session list, for re-posting on fresh skips. */ + sessions(): readonly ProjectSessionView[] { + return this.views + } + + /** Force the next push to re-list from the backend. */ + invalidateSessions(): void { + this.listed = 0 + } + + /** Insert or refresh one session in the cached list (creation, rename, fork). */ + upsertSession(view: ProjectSessionView): void { + this.views = [view, ...this.views.filter((item) => item.id !== view.id)] + this.live.add(view.id) + } + + /** Drop one session from the cached list (deletion, close). */ + removeLiveSession(id: string): void { + this.views = this.views.filter((item) => item.id !== id) + this.live.delete(id) + } + + /** Mark that the backend session list was just collected for this context. */ + markSessionsListed(): void { + this.listed = Date.now() + } + + /** Whether the last backend session listing is still fresh enough to reuse. */ + sessionsListedFresh(ms: number): boolean { + return this.listed > 0 && Date.now() - this.listed < ms + } + + missing(): boolean { + return !(this.deps.exists ?? fs.existsSync)(this.root) + } + + async dispose(): Promise { + if (this.phase === "disposed") return + this.version++ + this.phase = "disposing" + await this.init?.catch((err) => this.deps.log(`dispose: initialization failed: ${err}`)) + await this.mutation.catch((err) => this.deps.log(`dispose: mutation failed: ${err}`)) + await this.state?.flush().catch((err) => this.deps.log(`dispose: state flush failed: ${err}`)) + this.live.clear() + this.phase = "disposed" + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/contexts.ts b/packages/kilo-vscode/src/agent-manager/project/contexts.ts new file mode 100644 index 0000000000..a5ed3f3445 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/contexts.ts @@ -0,0 +1,266 @@ +/** + * ProjectContexts — coordinator owning all ProjectContext instances for one panel. + * + * Derives the pinned context from the current workspace root, creates registry + * project contexts on demand (expand/select), tracks which project is active + * and which are expanded, and serializes snapshots for the webview. See + * ./context.ts for the per-repository lifecycle policy. + */ + +import * as fs from "fs" +import * as path from "path" +import { canonicalizePath, projectIdFor, samePath } from "./paths" +import type { StoredProject } from "./registry" +import { ProjectContext, type ProjectContextDeps } from "./context" + +/** Serializable project description for the webview. */ +export interface ProjectSnapshot { + id: string + root: string + label: string + pinned: boolean + active: boolean + expanded: boolean + initialized: boolean + trusted: boolean + missing: boolean +} + +interface ContextsOptions { + /** Current VS Code workspace root; may change when workspace folders change. */ + workspaceRoot: () => string | undefined + registry: { + list(): StoredProject[] + get(id: string): StoredProject | undefined + } + /** Registry trust lookup for non-pinned projects. */ + trusted: (id: string) => boolean + /** Whether the multi-project experiment is enabled. */ + enabled: () => boolean + remove?: (id: string) => void + deps: ProjectContextDeps +} + +export class ProjectContexts { + private readonly contexts = new Map() + private activeId: string | undefined + private readonly expanded = new Set() + + constructor(private readonly opts: ContextsOptions) {} + + /** The pinned workspace project, derived lazily from the current workspace root. */ + pinned(): ProjectContext | undefined { + const root = this.opts.workspaceRoot() + if (!root) return undefined + const canonical = canonicalizePath(root) + return this.ensure(projectIdFor(canonical), canonical, true) + } + + private ensure(id: string, root: string, pinned: boolean): ProjectContext { + let ctx = this.contexts.get(id) + if (!ctx) { + ctx = new ProjectContext(id, root, pinned, this.opts.deps) + this.contexts.set(id, ctx) + } + return ctx + } + + /** Resolve any known project id to a context, creating it on demand. */ + private resolveCtx(id: string): ProjectContext | undefined { + const existing = this.contexts.get(id) + if (existing) return existing + const pinned = this.pinned() + if (pinned?.id === id) return pinned + if (!this.opts.enabled()) return undefined + const stored = this.opts.registry.get(id) + if (!stored) return undefined + return this.ensure(stored.id, stored.root, false) + } + + /** The active context. Defaults to the pinned project, or the first trusted registry project without a workspace. */ + active(): ProjectContext | undefined { + if (this.activeId) return this.contexts.get(this.activeId) + const pinned = this.pinned() + if (pinned) { + this.activeId = pinned.id + this.expanded.add(pinned.id) + return pinned + } + if (!this.opts.enabled()) return undefined + const first = this.opts.registry.list().find((p) => this.opts.trusted(p.id)) + if (!first) return undefined + const ctx = this.ensure(first.id, first.root, false) + this.activeId = ctx.id + this.expanded.add(ctx.id) + return ctx + } + + get(id: string): ProjectContext | undefined { + return this.contexts.get(id) + } + + /** The context that owns a directory: its root or one of its worktree paths. */ + byDirectory(dir: string): ProjectContext | undefined { + for (const ctx of this.contexts.values()) { + if (samePath(ctx.root, dir)) return ctx + const state = ctx.peekState() + if (state?.getWorktrees().some((wt) => wt.path && samePath(wt.path, dir))) return ctx + } + return undefined + } + + /** The context whose state owns the worktree id. */ + byWorktree(id: string): ProjectContext | undefined { + for (const ctx of this.contexts.values()) { + if (ctx.peekState()?.getWorktree(id)) return ctx + } + return undefined + } + + /** The context whose live session list contains the session. */ + byLiveSession(id: string): ProjectContext | undefined { + for (const ctx of this.contexts.values()) { + if (ctx.hasLiveSession(id)) return ctx + } + return undefined + } + + /** Resolve any known project id to a context without activating it. */ + resolve(id: string): ProjectContext | undefined { + return this.resolveCtx(id) + } + + /** Whether a project may be shown or initialized: known, flag-gated, and trusted. */ + usable(id: string): ProjectContext | undefined { + return this.usableCtx(id) + } + + isActive(id: string): boolean { + return this.active()?.id === id + } + + isExpanded(id: string): boolean { + return this.expanded.has(id) + } + + /** Make a project the active context and expand it. Returns undefined when not allowed. */ + activate(id: string): ProjectContext | undefined { + const ctx = this.usableCtx(id) + if (!ctx) return undefined + this.activeId = id + return ctx + } + + /** Expand a project without activating it. Returns undefined when not allowed. */ + expand(id: string): ProjectContext | undefined { + const ctx = this.usableCtx(id) + if (!ctx) return undefined + this.expanded.add(id) + return ctx + } + + collapse(id: string): void { + this.expanded.delete(id) + if (this.isActive(id)) return + this.contexts.get(id)?.suspend() + } + + /** Return ownership to pinned Local and suspend all secondary contexts. */ + disable(): ProjectContext | undefined { + const pinned = this.pinned() + this.activeId = pinned?.id + if (pinned) this.expanded.add(pinned.id) + for (const ctx of this.contexts.values()) { + if (ctx.pinned) continue + this.expanded.delete(ctx.id) + ctx.suspend() + // Match remove()/syncPinned(): drop the routes too, otherwise the shared + // route service accumulates entries for every disabled project. + this.opts.remove?.(ctx.id) + } + return pinned + } + + private usableCtx(id: string): ProjectContext | undefined { + const ctx = this.resolveCtx(id) + if (!ctx) return undefined + if (ctx.pinned) return ctx + if (!this.opts.enabled()) return undefined + if (!this.opts.trusted(id)) return undefined + return ctx + } + + /** Remove a non-pinned project context. Falls back to the pinned project when it was active. */ + async remove(id: string): Promise { + const ctx = this.contexts.get(id) + if (!ctx || ctx.pinned) return false + this.expanded.delete(id) + if (this.activeId === id) this.activeId = undefined + this.contexts.delete(id) + this.opts.remove?.(id) + await ctx.dispose() + return true + } + + /** + * Re-derive the pinned project after workspace folder changes. Disposes the + * old pinned context so cached services can never mix two roots. Returns + * true when the active context may have changed. + */ + syncPinned(): boolean { + const root = this.opts.workspaceRoot() + const next = root ? projectIdFor(canonicalizePath(root)) : undefined + const current = [...this.contexts.values()].find((ctx) => ctx.pinned)?.id + if (current === next) return false + for (const [id, ctx] of [...this.contexts]) { + if (!ctx.pinned) continue + this.contexts.delete(id) + this.expanded.delete(id) + if (this.activeId === id) this.activeId = undefined + this.opts.remove?.(id) + ctx.suspend() + void ctx.dispose() + } + return true + } + + /** Serializable snapshots for the webview: pinned first, then registry order. */ + snapshots(): ProjectSnapshot[] { + const out: ProjectSnapshot[] = [] + const pinned = this.pinned() + if (pinned) out.push(this.snapshot(pinned, undefined)) + if (!this.opts.enabled()) return out + for (const stored of this.opts.registry.list()) { + if (pinned?.id === stored.id) continue + out.push(this.snapshot(this.contexts.get(stored.id), stored)) + } + return out + } + + private snapshot(ctx: ProjectContext | undefined, stored: StoredProject | undefined): ProjectSnapshot { + const id = ctx?.id ?? stored!.id + const root = ctx?.root ?? stored!.root + const pinned = ctx?.pinned ?? false + return { + id, + root, + label: stored?.label || path.basename(root) || root, + pinned, + active: this.isActive(id), + expanded: this.isExpanded(id), + initialized: ctx?.loaded ?? false, + trusted: pinned || (stored?.trusted ?? false), + missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root), + } + } + + async dispose(): Promise { + for (const ctx of this.contexts.values()) { + this.opts.remove?.(ctx.id) + await ctx.dispose() + } + this.contexts.clear() + this.expanded.clear() + this.activeId = undefined + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/init.ts b/packages/kilo-vscode/src/agent-manager/project/init.ts new file mode 100644 index 0000000000..fc428c3a85 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/init.ts @@ -0,0 +1,237 @@ +/** + * Repository-local state initialization for one Agent Manager project context. + * + * Extracted from AgentManagerProvider so the same sequence runs for the + * active project (full panel wiring follows) and for expanded background + * projects (state push only). Ordering matters: the git exclude update must + * land before the persisted state file is read. + */ + +import { restoreWorktrees } from "../state-recovery" +import type { ProjectContext, ProjectInitResult } from "./context" +import type { Session } from "@kilocode/sdk/v2/client" +import type { ProjectRef, SessionRef, WorktreeRef } from "./route" +import { sessionToWebview } from "../../kilo-provider-utils" + +export type { ProjectSessionView } from "./session-view" +import type { ProjectSessionView } from "./session-view" + +/** + * Subset of {@link SessionProvider} needed to discover and register live + * project sessions by directory. Kept narrow so project-init stays testable + * without importing the full provider. Route registration methods are + * optional so tests and non-Agent-Manager callers can omit them. + */ +export interface ProjectSessionListing { + listSessions?(dir: string): Promise + setSessionDirectory(id: string, directory: string): void + /** Register a project root with the shared route service. */ + registerProjectRoute?(ref: ProjectRef, root: string, generation: number): void + /** Drop a project and all its session/worktree routes. */ + unregisterProjectRoute?(projectId: string): void + /** Register a worktree directory under a project. */ + registerWorktreeRoute?(ref: WorktreeRef, directory: string, generation: number): void + /** Register a session directory under a project (exact routing). */ + registerSessionRoute?(ref: SessionRef, directory: string, generation: number): void + /** Drop one session route (keeps the raw ambiguity index consistent). */ + unregisterSessionRoute?(ref: SessionRef): void +} + +/** + * Register the project root, every worktree directory, and every persisted + * managed session with the shared route service. Registered routes let the + * KiloProvider resolve project-qualified session refs to exact directories + * and detect ambiguous raw session ids across projects. Safe to call when the + * listing has no route methods: registration is simply skipped. + */ +function registerProjectRoutes(ctx: ProjectContext, sessions: ProjectSessionListing | undefined): void { + if (!sessions) return + const state = ctx.peekState() + sessions.registerProjectRoute?.({ projectId: ctx.id }, ctx.root, ctx.generation) + if (state) { + for (const wt of state.getWorktrees()) { + if (!wt.path) continue + sessions.registerWorktreeRoute?.({ projectId: ctx.id, worktreeId: wt.id }, wt.path, ctx.generation) + } + } +} + +/** Drop all routes for a project (e.g. when it is removed or disposed). */ +export function unregisterProjectRoutes(ctx: ProjectContext, sessions: ProjectSessionListing | undefined): void { + if (!sessions) return + sessions.unregisterProjectRoute?.(ctx.id) +} + +export async function initContextState( + ctx: ProjectContext, + log: (...args: unknown[]) => void, +): Promise { + return ctx.ensureReady(async (generation) => { + const manager = ctx.worktreeManager() + const state = ctx.stateManager() + await manager.ensureGitExclude().catch((err) => log("Failed to update git exclude:", err)) + if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 } + const loaded = await state.load() + if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 } + manager.cleanupOrphanedTempDirs() + + if (loaded.status === "failed" && !(await state.prepareRecovery())) { + return { ok: false, refsFixed: 0 } + } + if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 } + + const infos = await manager.discoverWorktrees().catch((err) => { + log("Failed to discover worktrees during state recovery:", err) + return [] + }) + if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 } + if (infos.length > 0) { + const result = restoreWorktrees(state, infos) + if (result.worktrees > 0 || result.sessions > 0) { + log(`Recovered ${result.worktrees} worktree(s) and ${result.sessions} session(s) from disk`) + await state.flush() + } + } + return { ok: true, refsFixed: loaded.refsFixed } + }) +} + +/** Register explicit Local/worktree routes for every persisted project session. */ +export function registerProjectSessions( + ctx: ProjectContext, + sessions: + | ({ + setSessionDirectory(id: string, directory: string): void + trackSession(id: string): void + } & Partial) + | undefined, +): void { + const state = ctx.peekState() + if (!state || !sessions) return + registerProjectRoutes(ctx, sessions) + for (const session of state.getSessions()) { + const worktree = session.worktreeId ? state.getWorktree(session.worktreeId) : undefined + const dir = worktree?.path ?? ctx.root + sessions.setSessionDirectory(session.id, dir) + sessions.trackSession(session.id) + sessions.registerSessionRoute?.({ projectId: ctx.id, sessionId: session.id }, dir, ctx.generation) + } +} + +/** + * Directories whose root sessions belong to this project: exactly {@link ProjectContext.root} + * plus the paths of every worktree tracked by this context's state. No active + * workspace root is ever mixed in, so sessions from unrelated projects cannot + * leak into the listing. + */ +export function projectDirectories(ctx: ProjectContext): string[] { + const state = ctx.peekState() + const dirs = new Set([ctx.root]) + if (state) for (const wt of state.getWorktrees()) if (wt.path) dirs.add(wt.path) + return [...dirs] +} + +/** + * Collect live root sessions for this project by listing exactly {@link ProjectContext.root} + * and each of its worktree directories through the SessionProvider. Each + * returned session is registered with the directory it was listed from before + * being included, so downstream backend requests route to the correct + * directory-scoped Instance. Managed placement is preserved separately by + * {@link registerProjectSessions}, which runs first and pins persisted managed + * sessions to their worktree paths; re-registering a managed session here with + * the same worktree path is a no-op. + * + * A failed directory listing (e.g. backend not connected, missing worktree) + * resolves to [] and never erases results from the other directories, so one + * failure cannot blank the whole project. + */ +export async function collectProjectSessions( + ctx: ProjectContext, + sessions: ProjectSessionListing | undefined, +): Promise { + if (!sessions?.listSessions) { + ctx.setLiveSessions([]) + return [] + } + const generation = ctx.generation + const list = sessions.listSessions + const state = ctx.peekState() + // Register the project root and worktree directories before listing so the + // route service can disambiguate sessions discovered in this project even + // when a raw session id collides with one in another project. + registerProjectRoutes(ctx, sessions) + const dirs = [ + { dir: ctx.root, worktreeId: null }, + ...(state + ?.getWorktrees() + .filter((wt) => !!wt.path) + .map((wt) => ({ dir: wt.path, worktreeId: wt.id })) ?? []), + ].filter((entry, index, all) => all.findIndex((item) => item.dir === entry.dir) === index) + const byDir = await Promise.all( + dirs.map(async (entry) => { + const items = await list(entry.dir).catch((err) => { + console.warn(`[Kilo New] Agent Manager: listing sessions for ${entry.dir} failed:`, err) + return [] as Session[] + }) + return { ...entry, items } + }), + ) + if (ctx.lifecycle === "disposed" || ctx.lifecycle === "disposing") return [] + if (!ctx.isCurrent(generation)) return [] + const seen = new Set() + const out: ProjectSessionView[] = [] + for (const { dir, worktreeId, items } of byDir) { + for (const s of items) { + if (seen.has(s.id)) continue + seen.add(s.id) + sessions.setSessionDirectory(s.id, dir) + // Register an exact route for every live session. Re-registering a + // managed session (already routed by registerProjectSessions) with the + // same directory is a no-op; a different directory would mean the + // session moved worktrees, and the latest registration wins. + sessions.registerSessionRoute?.({ projectId: ctx.id, sessionId: s.id }, dir, generation) + if (worktreeId) { + sessions.registerWorktreeRoute?.({ projectId: ctx.id, worktreeId }, dir, generation) + } + out.push({ ...sessionToWebview(s), worktreeId }) + } + } + return out +} + +export async function pushProjectSessions( + ctx: ProjectContext, + sessions: ProjectSessionListing | undefined, + post: (message: { type: "agentManager.projectSessions"; projectId: string; sessions: ProjectSessionView[] }) => void, +): Promise { + const gone = () => ctx.lifecycle === "disposed" || ctx.lifecycle === "disposing" + if (gone()) return + // Skip the backend round trips when the listing is still fresh, but always + // re-post the cached list: the webview may have mounted after the last push + // and has no other way to learn this project's sessions. + if (ctx.sessionsListedFresh(2000)) { + post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] }) + return + } + const views = await collectProjectSessions(ctx, sessions) + if (gone()) return + ctx.setSessions(views) + post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...views] }) +} + +/** + * Fast reactivation of an already-initialized context: re-register routes and + * push in-memory state without re-running the full initialization sequence + * (state load, worktree discovery, session listing, prompt recovery). Returns + * false when the context is not ready and the caller must run the full init. + */ +export function reactivateProject( + ctx: ProjectContext, + sessions: Parameters[1], + pushState: (ctx: ProjectContext) => void, +): boolean { + if (ctx.lifecycle !== "ready" || !ctx.peekState()) return false + registerProjectSessions(ctx, sessions) + pushState(ctx) + return true +} diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts new file mode 100644 index 0000000000..9bf8c542f8 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -0,0 +1,204 @@ +/** + * Project message handlers for the Agent Manager multi-project protocol. + * + * Extracted from AgentManagerProvider (file-size cap) and kept free of VS Code + * imports so the flows are unit-testable. All handlers fail closed: unknown + * projects, untrusted projects, and disabled experiments leave state untouched. + */ + +import simpleGit from "simple-git" +import type { AgentManagerInMessage } from "../types" +import type { ProjectRegistry } from "./registry" +import type { ProjectContext, ProjectInitResult } from "./context" +import type { ProjectContexts } from "./contexts" +import { projectIdFor, resolveProjectRoot, samePath } from "./paths" +import type { SidebarTarget } from "./route" + +export interface ProjectMessageDeps { + registry: ProjectRegistry + contexts: ProjectContexts + /** Whether the multi-project experiment is enabled. */ + enabled: () => boolean + /** Show a folder picker; resolves undefined when cancelled. */ + pickFolder: () => Promise + /** Re-initialize provider state for a freshly activated context. */ + activate: (ctx: ProjectContext) => void + /** Initialize an expanded background context and push its state. */ + expand: (ctx: ProjectContext) => void + /** Push the current project snapshots to the webview. */ + push: () => void + /** Acknowledge an atomically validated sidebar selection. */ + selected: (target: SidebarTarget) => void + /** Show a user-facing error. */ + error: (message: string) => void + /** Ensure a context's repository state is ready (no-op once initialized). */ + ready: (ctx: ProjectContext) => Promise + log: (...args: unknown[]) => void +} + +/** Handle a project-management message. Returns true when the message was consumed. */ +export async function handleProjectMessage(m: AgentManagerInMessage, deps: ProjectMessageDeps): Promise { + if (m.type === "agentManager.requestProjects") { + deps.push() + return true + } + if (m.type === "agentManager.addProject") { + await addProject(deps) + return true + } + if (m.type === "agentManager.removeProject") { + await removeProject(m.projectId, deps) + return true + } + if (m.type === "agentManager.selectProject") { + selectProject(m.projectId, deps) + return true + } + if (m.type === "agentManager.activateSelection") { + await activateSelection(m.target, deps, m.restore === true) + return true + } + if (m.type === "agentManager.rememberTarget") { + rememberTarget(m.projectId, m.target, deps) + return true + } + if (m.type === "agentManager.setProjectExpanded") { + await setExpanded(m.projectId, m.expanded, deps) + return true + } + if (m.type === "agentManager.trustProject") { + await trustProject(m.projectId, deps) + return true + } + return false +} + +async function activateSelection(requested: SidebarTarget, deps: ProjectMessageDeps, restore = false): Promise { + if (disabled(deps)) return + const ctx = deps.contexts.resolve(requested.projectId) + if (!ctx || !deps.contexts.usable(requested.projectId)) { + deps.error("The project is unavailable. Trust it first or check that the repository still exists.") + return + } + const result = await deps.ready(ctx) + if (!result.current || !result.ok) { + deps.error("The project is not ready yet. Expand it before selecting a worktree or session.") + deps.push() + return + } + const state = ctx.peekState() + // Restoring a project returns the user to their persisted target for it. + const persisted = restore ? state?.getActiveTarget() : undefined + const target = persisted?.projectId === requested.projectId ? persisted : requested + // A missing target is not actionable: the user clicked a sidebar row the + // extension itself offered, or the persisted restore target went stale. + // Fall back to the project's local context instead of an error toast. + if (target.kind === "worktree" && !state?.getWorktree(target.worktreeId)) { + deps.log(`selection target worktree ${target.worktreeId} is gone, falling back to local`) + return finish({ projectId: target.projectId, kind: "local" }, deps) + } + if (target.kind === "session" && !state?.getSession(target.sessionId) && !ctx.hasLiveSession(target.sessionId)) { + deps.log(`selection target session ${target.sessionId} is gone, falling back to local`) + return finish({ projectId: target.projectId, kind: "local" }, deps) + } + finish(target, deps) +} + +/** Commit the active project, persist the target, and acknowledge the selection. */ +function finish(target: SidebarTarget, deps: ProjectMessageDeps): void { + const previous = deps.contexts.active()?.id + const activated = deps.contexts.activate(target.projectId) + if (!activated) { + deps.error("The project is unavailable. Trust it first or check that the repository still exists.") + return + } + activated.peekState()?.setActiveTarget(target) + if (previous !== activated.id) deps.activate(activated) + deps.push() + deps.selected(target) +} + +/** Persist the webview's current selection without activating or validating anything. */ +function rememberTarget(projectId: string, target: SidebarTarget, deps: ProjectMessageDeps): void { + if (target.projectId !== projectId) return + const state = deps.contexts.get(projectId)?.peekState() + if (!state) return + // Never persist a target the project does not have: the webview can race a + // project switch and still hold the previous project's selection. + if (target.kind === "worktree" && !state.getWorktree(target.worktreeId)) return + if (target.kind === "session" && !state.getSession(target.sessionId)) return + state.setActiveTarget(target) +} + +function disabled(deps: ProjectMessageDeps): boolean { + if (deps.enabled()) return false + deps.error("Multi-project Agent Manager is disabled. Enable it in Kilo Settings > Experimental to add projects.") + return true +} + +async function addProject(deps: ProjectMessageDeps): Promise { + if (disabled(deps)) return + const dir = await deps.pickFolder() + if (!dir) return + // resolveProjectRoot (not resolveGitRoot) so a folder inside a linked worktree + // registers the primary checkout and cannot duplicate an existing project. + const root = await resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args)) + if (!root) { + deps.error("The selected folder is not inside a Git repository.") + return + } + const pinned = deps.contexts.pinned() + if (pinned && samePath(pinned.root, root)) { + deps.error("That repository is already the workspace project.") + return + } + const id = projectIdFor(root) + if (deps.registry.get(id)) { + deps.error("That repository is already registered as a project.") + return + } + try { + await deps.registry.add({ id, root }) + } catch (err) { + deps.log("addProject: registry write failed:", err) + deps.error("Failed to save the project. See the Agent Manager output for details.") + return + } + deps.log(`addProject: registered ${root}`) + deps.push() +} + +async function removeProject(id: string, deps: ProjectMessageDeps): Promise { + if (disabled(deps)) return + await deps.contexts.remove(id) + await deps.registry.remove(id) + deps.push() +} + +function selectProject(id: string, deps: ProjectMessageDeps): void { + if (disabled(deps)) return + const ctx = deps.contexts.activate(id) + if (!ctx) { + deps.error("The project is unavailable. Trust it first or check that the repository still exists.") + deps.push() + return + } + deps.activate(ctx) + deps.push() +} + +async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDeps): Promise { + if (disabled(deps)) return + if (expanded) { + const ctx = deps.contexts.expand(id) + if (ctx) deps.expand(ctx) + } + if (!expanded) deps.contexts.collapse(id) + deps.push() +} + +async function trustProject(id: string, deps: ProjectMessageDeps): Promise { + if (disabled(deps)) return + await deps.registry.setTrusted(id, true) + deps.push() +} diff --git a/packages/kilo-vscode/src/agent-manager/project/paths.ts b/packages/kilo-vscode/src/agent-manager/project/paths.ts new file mode 100644 index 0000000000..708a112212 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/paths.ts @@ -0,0 +1,80 @@ +/** + * Project path canonicalization for the Agent Manager project registry. + * + * Project identity is anchored to the canonical Git top-level path so that + * symlink aliases (e.g. /tmp -> /private/tmp) and case variants cannot + * register the same repository twice or route sessions to the wrong project. + */ + +import * as fs from "fs" +import * as path from "path" +import { createHash } from "crypto" +import { normalizePath } from "../git-import" + +/** Resolve symlinks and normalize separators. Falls back to lexical resolution when the path does not exist. */ +export function canonicalizePath(dir: string): string { + const resolved = path.resolve(dir) + try { + return normalizePath(fs.realpathSync.native(resolved)) + } catch { + return normalizePath(resolved) + } +} + +/** Compare two canonical paths. Case-insensitive filesystems compare folded. */ +export function samePath(a: string, b: string, platform: NodeJS.Platform = process.platform): boolean { + if (platform === "darwin" || platform === "win32") return a.toLowerCase() === b.toLowerCase() + return a === b +} + +/** + * Resolve the branch for the tracked worktree whose canonical path matches `dir`. + * + * `git worktree list --porcelain` realpath-resolves worktree registration, so + * tracked keys are canonical. The probe path is canonicalized the same way via + * `canonicalizePath` and compared with `samePath`, so symlink aliases such as + * /tmp -> /private/tmp and case variants on darwin/win32 cannot mark a real + * worktree missing. Tracked keys that no longer exist fall back to lexical + * normalization, preserving missing-path handling. Returns undefined when no + * tracked entry matches. + */ +export function findTrackedBranch( + tracked: Map, + dir: string, + platform: NodeJS.Platform = process.platform, +): string | undefined { + const canonical = canonicalizePath(dir) + for (const [key, branch] of tracked) { + if (samePath(canonicalizePath(key), canonical, platform)) return branch + } + return undefined +} + +/** Deterministic project id derived from the canonical root. Stable across restarts and panel recreations. */ +export function projectIdFor(root: string): string { + return `prj-${createHash("sha1").update(root).digest("hex").slice(0, 12)}` +} + +/** Resolve linked worktrees to the primary checkout so project-local state is shared by the repository. */ +export async function resolveProjectRoot( + dir: string, + git: (cwd: string, args: string[]) => Promise, +): Promise { + const run = (args: string[]) => + Promise.resolve() + .then(() => git(dir, args)) + .catch(() => undefined) + const revparse = (args: string[]) => run(["rev-parse", ...args]) + const top = await revparse(["--path-format=absolute", "--show-toplevel"]) + if (!top) return undefined + const root = canonicalizePath(top.trim()) + const [gitdir, common] = await Promise.all([ + revparse(["--path-format=absolute", "--git-dir"]), + revparse(["--path-format=absolute", "--git-common-dir"]), + ]) + if (!gitdir || !common) return root + if (samePath(canonicalizePath(gitdir.trim()), canonicalizePath(common.trim()))) return root + const listing = await run(["worktree", "list", "--porcelain", "-z"]) + const first = listing?.split("\0").find((field) => field.startsWith("worktree ")) + return first ? canonicalizePath(first.slice("worktree ".length)) : root +} diff --git a/packages/kilo-vscode/src/agent-manager/project/pollers.ts b/packages/kilo-vscode/src/agent-manager/project/pollers.ts new file mode 100644 index 0000000000..04bc8f023b --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/pollers.ts @@ -0,0 +1,179 @@ +/** + * Per-project stats/PR pollers for expanded background projects, plus the + * factory for the provider's poller trio (extracted from AgentManagerProvider + * for its file-size cap). + * + * The active project is served by the singleton `stats`/`pr` pollers (their + * getters follow the active context). `ProjectPollers` creates one poller pair + * per expanded non-active project so every visible project accordion receives + * live worktree stats, local stats, and PR statuses, each tagged with the + * owning projectId. VS Code-free and unit-testable: the poller pair factory is + * injectable. + */ + +import type { GitOps } from "../GitOps" +import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "../GitStatsPoller" +import { diffSummary } from "../local-diff" +import { PRStatusBridge } from "../pr-status-bridge" +import type { PRStatus } from "../types" +import type { ProjectContext } from "./context" +import type { ProjectContexts } from "./contexts" +import type { Semaphore } from "../semaphore" +import type { AgentManagerOutMessage, WorktreeDiffEntry } from "../types" +import type { WorktreeStateManager } from "../WorktreeStateManager" + +export interface PollerPair { + stats: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void } + pr: { poller: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void } } +} + +export type StatsOutMessage = + | { type: "agentManager.worktreeStats"; projectId?: string; stats: WorktreeStats[] } + | { type: "agentManager.localStats"; projectId?: string; stats: LocalStats } + | { type: "agentManager.prStatus"; projectId?: string; worktreeId: string; pr: PRStatus | null } + +type StatsMessage = Extract + +interface PollerDeps { + git: GitOps + semaphore: Semaphore + localDiff: (dir: string, base: string) => Promise + post: (msg: StatsOutMessage) => void + openExternal: (url: string) => void + visible: () => boolean + log: (...args: unknown[]) => void +} + +/** Create the real poller pair for one project context. */ +function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair { + const state = () => ctx.peekState() + const stats = new GitStatsPoller({ + getWorktrees: () => state()?.getWorktrees() ?? [], + getWorkspaceRoot: () => ctx.root, + localDiff: deps.localDiff, + git: deps.git, + semaphore: deps.semaphore, + log: deps.log, + onStats: (stats) => deps.post({ type: "agentManager.worktreeStats", projectId: ctx.id, stats }), + onLocalStats: (stats) => deps.post({ type: "agentManager.localStats", projectId: ctx.id, stats }), + }) + const pr = PRStatusBridge.create({ + getWorktrees: () => state()?.getWorktrees() ?? [], + getWorkspaceRoot: () => ctx.root, + postToWebview: (m) => deps.post({ ...m, projectId: ctx.id } as StatsOutMessage), + updateWorktreePR: (id, n, u, s) => state()?.updateWorktreePR(id, n, u, s), + hasPersistedPR: (id) => !!state()?.getWorktree(id)?.prNumber, + openExternal: deps.openExternal, + log: deps.log, + semaphore: deps.semaphore, + }) + return { stats, pr } +} + +export class ProjectPollers { + private readonly pollers = new Map() + + constructor( + private readonly deps: PollerDeps, + private readonly create: (ctx: ProjectContext, deps: PollerDeps) => PollerPair = createPollerPair, + ) {} + + /** + * Reconcile pollers with the current expanded set: start pollers for + * expanded, trusted, non-active projects whose state is initialized, and + * stop pollers for projects that were collapsed, removed, or activated. + */ + sync(contexts: ProjectContexts): void { + const wanted = new Set() + for (const snap of contexts.snapshots()) { + if (snap.active || !snap.expanded || !snap.trusted || snap.missing) continue + const ctx = contexts.get(snap.id) + if (!ctx?.peekState()) continue + wanted.add(snap.id) + if (this.pollers.has(snap.id)) continue + const pair = this.create(ctx, this.deps) + pair.stats.setVisible(this.deps.visible()) + pair.pr.poller.setVisible(this.deps.visible()) + pair.stats.setEnabled(true) + pair.pr.poller.setEnabled(true) + this.pollers.set(snap.id, pair) + } + for (const [id, pair] of [...this.pollers]) { + if (wanted.has(id)) continue + pair.stats.stop() + pair.pr.poller.stop() + this.pollers.delete(id) + } + } + + setVisible(visible: boolean): void { + for (const pair of this.pollers.values()) { + pair.stats.setVisible(visible) + pair.pr.poller.setVisible(visible) + } + } + + dispose(): void { + for (const pair of this.pollers.values()) { + pair.stats.stop() + pair.pr.poller.stop() + } + this.pollers.clear() + } +} + +/** Create the provider's poller trio: singleton active-project pollers plus per-project background pollers. */ +export function createPollers(opts: { + git: GitOps + semaphore: Semaphore + state: () => WorktreeStateManager | undefined + root: () => string | undefined + activeId: () => string | undefined + visible: () => boolean + post: (msg: AgentManagerOutMessage) => void + cache: (msg: StatsMessage) => void + presence: (result: WorktreePresenceResult) => void + openExternal: (url: string) => void + log: (...args: unknown[]) => void +}): { stats: GitStatsPoller; pr: PRStatusBridge; projects: ProjectPollers } { + const localDiff = (dir: string, base: string) => diffSummary(opts.git, dir, base, opts.log) + const stats = new GitStatsPoller({ + getWorktrees: () => opts.state()?.getWorktrees() ?? [], + getWorkspaceRoot: opts.root, + localDiff, + semaphore: opts.semaphore, + onStats: (stats) => { + const msg = { type: "agentManager.worktreeStats" as const, projectId: opts.activeId(), stats } + opts.cache(msg) + opts.post(msg) + }, + onLocalStats: (stats) => { + const msg = { type: "agentManager.localStats" as const, projectId: opts.activeId(), stats } + opts.cache(msg) + opts.post(msg) + }, + onWorktreePresence: opts.presence, + log: opts.log, + git: opts.git, + }) + const pr = PRStatusBridge.create({ + getWorktrees: () => opts.state()?.getWorktrees() ?? [], + getWorkspaceRoot: opts.root, + postToWebview: (m) => opts.post(m.type === "agentManager.prStatus" ? { ...m, projectId: opts.activeId() } : m), + updateWorktreePR: (id, n, u, s) => opts.state()?.updateWorktreePR(id, n, u, s), + hasPersistedPR: (id) => !!opts.state()?.getWorktree(id)?.prNumber, + openExternal: opts.openExternal, + log: opts.log, + semaphore: opts.semaphore, + }) + const projects = new ProjectPollers({ + git: opts.git, + semaphore: opts.semaphore, + localDiff, + post: opts.post, + openExternal: opts.openExternal, + visible: opts.visible, + log: opts.log, + }) + return { stats, pr, projects } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/registry.ts b/packages/kilo-vscode/src/agent-manager/project/registry.ts new file mode 100644 index 0000000000..9b11a11513 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/registry.ts @@ -0,0 +1,177 @@ +/** + * ProjectRegistry — global catalog of additional Agent Manager projects. + * + * The registry persists only *additional* projects: repositories the user + * explicitly added through the Agent Manager project picker. The pinned + * default project is always derived from the current VS Code workspace at + * runtime and is never stored here. + * + * Storage is injected so the registry stays free of VS Code imports and can + * be unit-tested with an in-memory store. The file is versioned; corrupt or + * foreign content fails closed to an empty catalog and is repaired by the + * next mutation. + * + * Concurrency model: every mutation is funneled through a single in-instance + * queue, and each mutation re-reads storage immediately before applying its + * change. That makes a mutation atomic with respect to other mutations on the + * same instance and lets it merge with the latest persisted state even when + * the in-memory cache is stale (another window wrote in the meantime). The + * cache is reassigned only after the write succeeds, so a failed write never + * leaves memory ahead of storage. There is no cross-process lock by design. + */ + +export interface StoredProject { + /** Deterministic id from projectIdFor(root). */ + id: string + /** Canonical Git top-level path. */ + root: string + /** Optional user-facing display name. */ + label?: string + order: number + /** Whether project-controlled scripts may execute for this project. */ + trusted: boolean + addedAt: string +} + +interface RegistryFile { + version: 1 + projects: StoredProject[] +} + +export interface RegistryStorage { + read(): unknown + write(value: unknown): Promise | void +} + +const VERSION = 1 + +function valid(entry: unknown): entry is StoredProject { + if (!entry || typeof entry !== "object") return false + const e = entry as Record + return ( + typeof e.id === "string" && + typeof e.root === "string" && + typeof e.order === "number" && + typeof e.trusted === "boolean" && + typeof e.addedAt === "string" + ) +} + +function parse(raw: unknown, log: (msg: string) => void): StoredProject[] { + if (!raw || typeof raw !== "object") return [] + const file = raw as Partial + if (file.version !== VERSION || !Array.isArray(file.projects)) { + if (file.version !== undefined) log("project registry has an unsupported shape, starting empty") + return [] + } + const seen = new Set() + const out: StoredProject[] = [] + for (const entry of file.projects) { + if (!valid(entry)) continue + if (seen.has(entry.id)) continue + seen.add(entry.id) + out.push(entry) + } + return out.sort((a, b) => a.order - b.order) +} + +export class ProjectRegistry { + private projects: StoredProject[] | undefined + private queue: Promise = Promise.resolve() + + constructor( + private readonly storage: RegistryStorage, + private readonly log: (msg: string) => void = () => {}, + ) {} + + private load(): StoredProject[] { + this.projects ??= parse(this.storage.read(), this.log) + return this.projects + } + + /** Fresh re-read + validate/dedupe/sort, used by every mutation. */ + private fresh(): StoredProject[] { + return parse(this.storage.read(), this.log) + } + + /** Persist the next catalog and update the cache only after the write succeeds. */ + private async write(next: StoredProject[]): Promise { + const file: RegistryFile = { version: VERSION, projects: next } + await this.storage.write(file) + this.projects = next + } + + /** Serialize mutations within this instance; a failed mutation does not poison the queue. */ + private run(fn: () => Promise): Promise { + const result = this.queue.catch(() => {}).then(fn) + this.queue = result.then( + () => {}, + () => {}, + ) + return result + } + + list(): StoredProject[] { + return [...this.load()] + } + + get(id: string): StoredProject | undefined { + return this.load().find((p) => p.id === id) + } + + /** Register an additional project. Throws when the id is already registered. */ + add(input: { id: string; root: string; label?: string }): Promise { + return this.run(() => this.doAdd(input)) + } + + private async doAdd(input: { id: string; root: string; label?: string }): Promise { + const current = this.fresh() + if (current.find((p) => p.id === input.id)) throw new Error("That repository is already registered as a project.") + const order = current.reduce((max, p) => Math.max(max, p.order), 0) + 1 + const project: StoredProject = { + id: input.id, + root: input.root, + label: input.label, + order, + trusted: false, + addedAt: new Date().toISOString(), + } + await this.write([...current, project]) + return project + } + + /** Remove a project from the catalog. Never deletes repository data. */ + remove(id: string): Promise { + return this.run(() => this.doRemove(id)) + } + + private async doRemove(id: string): Promise { + const current = this.fresh() + const next = current.filter((p) => p.id !== id) + if (next.length === current.length) return false + await this.write(next) + return true + } + + setTrusted(id: string, trusted: boolean): Promise { + return this.run(() => this.doSetTrusted(id, trusted)) + } + + private async doSetTrusted(id: string, trusted: boolean): Promise { + const current = this.fresh() + if (!current.find((p) => p.id === id)) return false + await this.write(current.map((p) => (p.id === id ? { ...p, trusted } : p))) + return true + } + + setLabel(id: string, label: string | undefined): Promise { + return this.run(() => this.doSetLabel(id, label)) + } + + private async doSetLabel(id: string, label: string | undefined): Promise { + const current = this.fresh() + if (!current.find((p) => p.id === id)) return false + await this.write(current.map((p) => (p.id === id ? { ...p, label } : p))) + return true + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/route.ts b/packages/kilo-vscode/src/agent-manager/project/route.ts new file mode 100644 index 0000000000..8504e65227 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/route.ts @@ -0,0 +1,197 @@ +/** Strict project/resource ownership for Agent Manager multi-project routing. */ + +export interface ProjectRef { + projectId: string +} + +export interface WorktreeRef extends ProjectRef { + worktreeId: string +} + +export interface SessionRef extends ProjectRef { + sessionId: string +} + +export type SidebarTarget = + | { projectId: string; kind: "local" } + | { projectId: string; kind: "worktree"; worktreeId: string } + | { projectId: string; kind: "session"; sessionId: string } + +export type ProjectRouteErrorCode = + | "project_unknown" + | "project_stale" + | "worktree_unknown" + | "session_unknown" + | "session_ambiguous" + +export class ProjectRouteError extends Error { + constructor( + readonly code: ProjectRouteErrorCode, + message: string, + ) { + super(message) + } +} + +interface ProjectRoute { + root: string + generation: number + worktrees: Map +} + +interface SessionRoute { + projectId: string + directory: string + generation: number +} + +const key = (projectId: string, id: string) => `${projectId}\0${id}` + +export class ProjectRouteService { + private readonly projects = new Map() + private readonly sessions = new Map() + private readonly raw = new Map>() + + registerProject(projectId: string, root: string, generation: number): void { + const current = this.projects.get(projectId) + if (current && (current.generation !== generation || current.root !== root)) this.unregisterProject(projectId) + if (current && current.generation === generation && current.root === root) return + this.projects.set(projectId, { root, generation, worktrees: new Map() }) + } + + unregisterProject(projectId: string): void { + this.projects.delete(projectId) + for (const [id, route] of [...this.sessions]) { + if (route.projectId !== projectId) continue + this.sessions.delete(id) + const sessionId = id.slice(id.indexOf("\0") + 1) + const projects = this.raw.get(sessionId) + projects?.delete(projectId) + if (projects?.size === 0) this.raw.delete(sessionId) + } + } + + registerWorktree(ref: WorktreeRef, directory: string, generation: number): void { + const project = this.requireProject(ref.projectId, generation) + project.worktrees.set(ref.worktreeId, directory) + } + + registerSession(ref: SessionRef, directory: string, generation: number): void { + this.requireProject(ref.projectId, generation) + this.sessions.set(key(ref.projectId, ref.sessionId), { projectId: ref.projectId, directory, generation }) + const projects = this.raw.get(ref.sessionId) ?? new Set() + projects.add(ref.projectId) + this.raw.set(ref.sessionId, projects) + } + + /** + * Drop a single session route. Safe to call for unknown refs; never throws. + * Keeps the raw index consistent so an unregistered id cannot remain + * ambiguous or stale. + */ + unregisterSession(ref: SessionRef): void { + if (!this.sessions.delete(key(ref.projectId, ref.sessionId))) return + const projects = this.raw.get(ref.sessionId) + projects?.delete(ref.projectId) + if (projects?.size === 0) this.raw.delete(ref.sessionId) + } + + /** Whether a session route is currently registered for this ref. */ + hasSession(ref: SessionRef): boolean { + return this.sessions.has(key(ref.projectId, ref.sessionId)) + } + + /** + * Best-effort directory for a raw session id, without throwing. + * + * Returns the exact directory when the id maps to exactly one project, + * `undefined` when unknown, and `undefined` when the id is ambiguous across + * projects. Callers must NOT fall back to an arbitrary root for an + * ambiguous id — that would silently retarget the operation to the wrong + * project. Use {@link resolveRawSession} when a precise ref is required. + */ + trySessionDirectory(sessionId: string): string | undefined { + const projects = this.raw.get(sessionId) + if (!projects?.size) return undefined + if (projects.size > 1) return undefined + const projectId = [...projects][0]! + const route = this.sessions.get(key(projectId, sessionId)) + if (!route) return undefined + if (!this.projects.has(projectId)) return undefined + return route.directory + } + + /** Whether a raw session id is known but maps to more than one project. */ + isSessionAmbiguous(sessionId: string): boolean { + const projects = this.raw.get(sessionId) + return !!projects && projects.size > 1 + } + + inheritSession(ref: SessionRef, parent: SessionRef): void { + const route = this.requireSession(parent) + if (ref.projectId !== parent.projectId) { + throw new ProjectRouteError("session_unknown", "A child session cannot move between projects.") + } + this.registerSession(ref, route.directory, route.generation) + } + + projectRoot(ref: ProjectRef, generation?: number): string { + return this.requireProject(ref.projectId, generation).root + } + + worktreeDirectory(ref: WorktreeRef, generation?: number): string { + const project = this.requireProject(ref.projectId, generation) + const directory = project.worktrees.get(ref.worktreeId) + if (!directory) throw new ProjectRouteError("worktree_unknown", `Unknown worktree ${ref.worktreeId}.`) + return directory + } + + sessionDirectory(ref: SessionRef): string { + return this.requireSession(ref).directory + } + + /** + * Non-throwing variant of {@link sessionDirectory}. Returns the exact + * directory for a project-qualified session ref, or `undefined` when the + * project or session is unknown. Used by the KiloProvider adapter to route + * Agent Manager operations to an exact directory without risking an + * exception in message-handling paths. + */ + trySessionDirectoryFor(ref: SessionRef): string | undefined { + const project = this.projects.get(ref.projectId) + if (!project) return undefined + const route = this.sessions.get(key(ref.projectId, ref.sessionId)) + if (!route) return undefined + if (route.generation !== project.generation) return undefined + return route.directory + } + + resolveRawSession(sessionId: string): SessionRef { + const projects = this.raw.get(sessionId) + if (!projects?.size) throw new ProjectRouteError("session_unknown", `Unknown session ${sessionId}.`) + if (projects.size > 1) + throw new ProjectRouteError("session_ambiguous", `Session ${sessionId} exists in multiple projects.`) + return { projectId: [...projects][0]!, sessionId } + } + + static key(ref: ProjectRef & { sessionId?: string; worktreeId?: string }): string { + const id = ref.sessionId ?? ref.worktreeId ?? "local" + return key(ref.projectId, id) + } + + private requireProject(projectId: string, generation?: number): ProjectRoute { + const project = this.projects.get(projectId) + if (!project) throw new ProjectRouteError("project_unknown", `Unknown project ${projectId}.`) + if (generation !== undefined && generation !== project.generation) { + throw new ProjectRouteError("project_stale", `Project ${projectId} has been replaced.`) + } + return project + } + + private requireSession(ref: SessionRef): SessionRoute { + const route = this.sessions.get(key(ref.projectId, ref.sessionId)) + if (!route) throw new ProjectRouteError("session_unknown", `Unknown session ${ref.sessionId} in ${ref.projectId}.`) + this.requireProject(ref.projectId, route.generation) + return route + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/scope.ts b/packages/kilo-vscode/src/agent-manager/project/scope.ts new file mode 100644 index 0000000000..8c9780f2e5 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/scope.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from "async_hooks" +import type { ProjectContext } from "./context" + +/** Pins dynamic provider getters to the project that started an async operation. */ +export class ProjectScope { + private readonly storage = new AsyncLocalStorage() + + current(): ProjectContext | undefined { + return this.storage.getStore() + } + + run(ctx: ProjectContext, operation: () => Promise): Promise { + return this.storage.run(ctx, operation) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/session-view.ts b/packages/kilo-vscode/src/agent-manager/project/session-view.ts new file mode 100644 index 0000000000..f8643d21e9 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/session-view.ts @@ -0,0 +1,4 @@ +import type { sessionToWebview } from "../../kilo-provider-utils" + +/** A root session as shown in a project's sidebar, tagged with its worktree. */ +export type ProjectSessionView = ReturnType & { worktreeId: string | null } diff --git a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts new file mode 100644 index 0000000000..46aa539225 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts @@ -0,0 +1,35 @@ +/** + * Messages that mutate or read a project's persisted state and therefore must + * wait for that project's state to load before dispatch. + */ +export const STATE_GATED = new Set([ + "agentManager.createWorktree", + "agentManager.promoteSession", + "agentManager.createMultiVersion", + "agentManager.deleteWorktree", + "agentManager.removeStaleWorktree", + "agentManager.openLocally", + "agentManager.addSessionToWorktree", + "agentManager.closeSession", + "agentManager.persistSession", + "agentManager.forgetSession", + "agentManager.renameWorktree", + "agentManager.requestBranches", + "agentManager.importFromBranch", + "agentManager.importFromPR", + "agentManager.importExternalWorktree", + "agentManager.importAllExternalWorktrees", + "agentManager.setTabOrder", + "agentManager.setWorktreeOrder", + "agentManager.setSessionsCollapsed", + "agentManager.setSidebarCollapsed", + "agentManager.setReviewDiffStyle", + "agentManager.setDefaultBaseBranch", + "agentManager.createSection", + "agentManager.renameSection", + "agentManager.deleteSection", + "agentManager.setSectionColor", + "agentManager.toggleSectionCollapsed", + "agentManager.moveToSection", + "agentManager.moveSection", +]) diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts new file mode 100644 index 0000000000..f3673365ac --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -0,0 +1,94 @@ +/** + * Factory for the Agent Manager multi-project wiring: registry, context + * coordinator, message-handler dependencies, and host change listeners. + * + * Extracted from AgentManagerProvider (file-size cap). VS Code-free; the Host + * interface abstracts all platform capabilities. + */ + +import type { Host, Disposable } from "../host" +import type { GitOps } from "../GitOps" +import { ProjectRegistry } from "./registry" +import type { ProjectContext, ProjectInitResult } from "./context" +import { ProjectContexts, type ProjectSnapshot } from "./contexts" +import type { ProjectMessageDeps } from "./messages" + +export interface ProjectWiring { + registry: ProjectRegistry + contexts: ProjectContexts + messages: ProjectMessageDeps + /** Payload for the agentManager.projects webview message. */ + snapshots(): { type: "agentManager.projects"; multiProject: boolean; projects: ProjectSnapshot[] } + dispose(): void +} + +export function createProjectWiring(opts: { + host: Host + git: GitOps + log: (...args: unknown[]) => void + output: (msg: string) => void + /** Re-initialize provider state for a freshly activated context. */ + activate: (ctx: ProjectContext) => void + /** Initialize an expanded background context and push its state. */ + expand: (ctx: ProjectContext) => void + /** Ensure a context's repository state is ready (no-op once initialized). */ + ready: (ctx: ProjectContext) => Promise + /** Push the project catalog to the webview. */ + push: () => void + /** Re-derive the pinned project after workspace folder changes. */ + changed: () => void + /** Re-push worktree state (e.g. after the flag toggles). */ + refresh: () => void + /** Acknowledge an atomically validated sidebar selection. */ + selected: (target: import("./route").SidebarTarget) => void +}): ProjectWiring { + const registry = new ProjectRegistry( + { read: () => opts.host.readProjects(), write: (value) => opts.host.writeProjects(value) }, + (msg) => opts.log(msg), + ) + const contexts = new ProjectContexts({ + workspaceRoot: () => opts.host.workspacePath(), + registry, + trusted: (id) => registry.get(id)?.trusted === true, + enabled: () => opts.host.multiProject(), + remove: (id) => opts.host.unregisterProjectRoutes(id), + deps: { log: opts.output, git: opts.git }, + }) + const messages: ProjectMessageDeps = { + registry, + contexts, + enabled: () => opts.host.multiProject(), + pickFolder: () => opts.host.pickFolder(), + activate: opts.activate, + expand: opts.expand, + ready: opts.ready, + push: opts.push, + selected: opts.selected, + error: (message) => opts.host.showError(message), + log: opts.log, + } + const listeners: Disposable[] = [ + opts.host.onDidChangeWorkspaceFolders(() => opts.changed()), + opts.host.onDidChangeMultiProject((enabled) => { + if (!enabled) { + const pinned = contexts.disable() + if (pinned) opts.activate(pinned) + } + opts.push() + opts.refresh() + }), + ] + return { + registry, + contexts, + messages, + snapshots: () => ({ + type: "agentManager.projects", + multiProject: opts.host.multiProject(), + projects: contexts.snapshots(), + }), + dispose: () => { + for (const listener of listeners) listener.dispose() + }, + } +} diff --git a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts new file mode 100644 index 0000000000..a9aa615fd6 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts @@ -0,0 +1,299 @@ +import type { KiloClient, Session } from "@kilocode/sdk/v2/client" +import { getErrorMessage } from "../kilo-provider-utils" +import type { AgentManagerOutMessage } from "./types" +import { PLATFORM } from "./constants" +import { initContextState } from "./project/init" +import type { ProjectContext } from "./project/context" +import type { ManagedSession } from "./WorktreeStateManager" +import type { CreateWorktreeResult, WorktreeManager } from "./WorktreeManager" +import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./worktree-create" +import { recordPromotionHandoff } from "./promotion-handoff" +import { stopSessionProcesses } from "../kilo-provider/background-process" + +/** + * Provider capabilities the worktree lifecycle needs beyond project state. + * State is reached through the ProjectContext the handler receives; this host + * only carries what genuinely belongs to the provider: shared creation + * helpers, the panel session facade, route registration, poller skips, the + * diff controller, telemetry, and the webview boundary. + */ +export interface LifecycleHost { + createOnDisk: (opts?: CreateWorktreeOnDiskOptions) => Promise + runSetup: (dir: string, branch: string, id: string) => Promise + createSession: (dir: string, branch: string, id: string) => Promise + notifyReady: (sessionId: string, result: CreateWorktreeResult, worktreeId?: string) => void + sessions: { + register: (session: Session) => void + clearDirectory: (sessionId: string) => void + directories: () => ReadonlyMap | undefined + abort: (sessionIds: string[]) => Promise + forget: (sessionId: string) => void + } + push: () => void + register: (sessionId: string, dir: string) => void + skipStats: (worktreeId: string) => void + unskipStats: (worktreeId: string) => void + removePR: (worktreeId: string) => void + removeRun: (worktreeId: string) => Promise + /** Stop the run script's terminal; false aborts the worktree removal. */ + clearRun: (worktreeId: string) => Promise + forgetName: (worktreeId: string) => void + stopDiffs: (path: string, orphaned: ManagedSession[]) => void + capture: (event: string, props: Record) => void + autoName: () => { enabled: boolean } + client: () => KiloClient + metadata: (client: KiloClient, dir: string) => Promise> + post: (message: AgentManagerOutMessage) => void + log: (...args: unknown[]) => void +} + +/** Create a new worktree with an auto-created first session. */ +export async function createLifecycleWorktree( + ctx: ProjectContext, + host: LifecycleHost, + opts: { baseBranch?: string; branchName?: string }, +): Promise { + await initContextState(ctx, host.log) + + const created = await host.createOnDisk({ baseBranch: opts.baseBranch, branchName: opts.branchName }) + if (!created) return null + + // Run setup script for new worktree (blocks until complete, shows in overlay) + await host.runSetup(created.result.path, created.result.branch, created.worktree.id) + + const session = await host.createSession(created.result.path, created.result.branch, created.worktree.id) + if (!session) { + ctx.peekState()?.removeWorktree(created.worktree.id) + await ctx.worktreeManager().removeWorktree(created.result.path) + host.push() + return null + } + + const state = ctx.peekState()! + state.addSession(session.id, created.worktree.id) + if (!opts.branchName && host.autoName().enabled) state.armAutoName(created.worktree.id, session.id) + host.register(session.id, created.result.path) + // Push state before registerSession so the webview's sessionCreated handler + // sees the worktree mapping and routes the session to the worktree tab. + host.notifyReady(session.id, created.result, created.worktree.id) + host.sessions.register(session) + host.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId: session.id, + worktreeId: created.worktree.id, + branch: created.result.branch, + }) + host.log(`Created worktree ${created.worktree.id} with session ${session.id}`) + return null +} + +/** Delete a worktree and dissociate its sessions. */ +export async function deleteLifecycleWorktree( + ctx: ProjectContext, + host: LifecycleHost, + worktreeId: string, +): Promise { + const state = ctx.peekState() + if (!state) return null + const worktree = state.getWorktree(worktreeId) + if (!worktree) { + host.log(`Worktree ${worktreeId} not found in state`) + return null + } + // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. + // Pre-emptive skip covers any in-flight poll that already captured getWorktrees(). + host.skipStats(worktreeId) + await host.removeRun(worktreeId) + if (!(await host.clearRun(worktreeId))) { + host.unskipStats(worktreeId) + host.post({ type: "error", message: "Failed to stop the Run script before deleting the worktree" }) + return null + } + host.removePR(worktreeId) + host.forgetName(worktreeId) + const orphaned = state.removeWorktree(worktreeId) + host.stopDiffs(worktree.path, orphaned) + for (const s of orphaned) host.sessions.clearDirectory(s.id) + host.push() + // Disk removal after state is clean — pollers no longer reference this worktree. + const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch) + try { + await ctx.worktreeManager().removeWorktree(worktree.path, branch) + } catch (error) { + host.log(`Failed to remove worktree from disk: ${error}`) + } + host.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`) + return null +} + +/** Remove a stale worktree entry from state without touching the filesystem. */ +export async function removeStaleLifecycleWorktree( + ctx: ProjectContext, + host: LifecycleHost, + worktreeId: string, +): Promise { + const state = ctx.peekState() + if (!state) return null + if (!ctx.stale.has(worktreeId)) { + host.log(`Ignored stale removal for non-stale worktree ${worktreeId}`) + return null + } + + const worktree = state.getWorktree(worktreeId) + if (!worktree) { + ctx.stale.delete(worktreeId) + host.push() + return null + } + + await host.removeRun(worktreeId) + if (!(await host.clearRun(worktreeId))) { + host.post({ type: "error", message: "Failed to stop the Run script before removing the worktree" }) + return null + } + host.forgetName(worktreeId) + const orphaned = state.removeWorktree(worktreeId) + host.stopDiffs(worktree.path, orphaned) + for (const session of orphaned) host.sessions.clearDirectory(session.id) + ctx.stale.delete(worktreeId) + host.push() + host.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`) + return null +} + +/** Promote a session: create a worktree and move the session into it. */ +export async function promoteLifecycleSession( + ctx: ProjectContext, + host: LifecycleHost, + sessionId: string, +): Promise { + await initContextState(ctx, host.log) + const created = await host.createOnDisk({}) + if (!created) return null + + // Run setup script for new worktree (blocks until complete, shows in overlay) + await host.runSetup(created.result.path, created.result.branch, created.worktree.id) + + const state = ctx.peekState()! + if (!state.getSession(sessionId)) { + state.addSession(sessionId, created.worktree.id) + } else { + state.moveSession(sessionId, created.worktree.id) + } + + host.register(sessionId, created.result.path) + try { + await recordPromotionHandoff({ + client: host.client(), + sessionId, + directory: created.result.path, + branch: created.result.branch, + }) + } catch (err) { + host.log("Failed to record worktree promotion handoff:", getErrorMessage(err)) + } + host.notifyReady(sessionId, created.result, created.worktree.id) + host.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`) + return null +} + +/** Add a new or existing session to an existing worktree. */ +export async function addSessionToLifecycleWorktree( + ctx: ProjectContext, + host: LifecycleHost, + worktreeId: string, + sessionId?: string, +): Promise { + let client: KiloClient + try { + client = host.client() + } catch (err) { + host.log("onAddSessionToWorktree: client not available:", err) + host.post({ type: "error", message: "Not connected to CLI backend" }) + return null + } + + const state = ctx.peekState() + if (!state) return null + + const worktree = state.getWorktree(worktreeId) + if (!worktree) { + host.log(`Worktree ${worktreeId} not found`) + return null + } + + if (sessionId) { + if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId) + else state.addSession(sessionId, worktreeId) + host.register(sessionId, worktree.path) + host.push() + host.post({ type: "agentManager.sessionAdded", sessionId, worktreeId }) + host.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId, + worktreeId, + existing: true, + }) + host.log(`Added existing session ${sessionId} to worktree ${worktreeId}`) + return null + } + + let session: Session + try { + const metadata = await host.metadata(client, worktree.path) + const { data } = await client.session.create( + { directory: worktree.path, platform: PLATFORM, metadata }, + { throwOnError: true }, + ) + session = data + } catch (error) { + const err = getErrorMessage(error) + host.post({ type: "error", message: `Failed to create session: ${err}` }) + host.capture("Agent Manager Session Error", { + source: PLATFORM, + error: err, + context: "addSessionToWorktree", + worktreeId, + }) + return null + } + + state.addSession(session.id, worktreeId) + host.register(session.id, worktree.path) + host.push() + host.post({ type: "agentManager.sessionAdded", sessionId: session.id, worktreeId }) + host.sessions.register(session) + + host.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId: session.id, + worktreeId, + }) + host.log(`Added session ${session.id} to worktree ${worktreeId}`) + return null +} + +/** Stop a session and remove it from Agent Manager. */ +export async function closeLifecycleSession( + ctx: ProjectContext, + host: LifecycleHost, + sessionId: string, +): Promise { + const state = ctx.peekState() + const dir = state?.directoryFor(sessionId) ?? host.sessions.directories()?.get(sessionId) ?? ctx.root ?? process.cwd() + await host.sessions.abort([sessionId]) + host.sessions.forget(sessionId) + try { + await stopSessionProcesses(host.client(), sessionId, dir) + } catch (err) { + host.log("onCloseSession: client not available:", err) + } + + state?.removeSession(sessionId) + host.sessions.clearDirectory(sessionId) + if (state) host.push() + host.log(`Closed session ${sessionId}`) + return null +} + +export type { WorktreeManager } diff --git a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts new file mode 100644 index 0000000000..37a9fe93a1 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts @@ -0,0 +1,262 @@ +import { getErrorMessage } from "../kilo-provider-utils" +import { PLATFORM } from "./constants" +import type { ProjectContext } from "./project/context" +import type { AgentManagerInMessage } from "./types" +import { versionedName } from "./branch-name" +import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" +import { ensureSandbox } from "./sandbox-bootstrap" +import type { LifecycleHost } from "./provider-lifecycle" + +/** + * Multi-version creation needs the lifecycle capabilities plus three provider + * services of its own: worktree discard for failed versions, the branch + * naming controller for the initial prompt, and user-facing error reporting. + */ +export interface MultiVersionHost extends LifecycleHost { + discard: (id: string, dir: string, branch: string, sessionId?: string) => Promise + promptName: (input: { sessionID: string; text: string; providerID?: string; modelID?: string }) => void + error: (message: string) => void +} + +/** + * Create N worktrees with one session each (optionally one model per version), + * then fan the initial prompt out to every created session. State is reached + * through the project context; everything else goes through the host. + */ +export async function createMultiVersion( + ctx: ProjectContext, + host: MultiVersionHost, + msg: Extract, +): Promise { + const text = msg.text?.trim() || undefined + + const worktreeName = msg.name?.trim() || undefined + const agent = msg.agent + const files = msg.files + const baseBranch = msg.baseBranch + const branchName = msg.branchName?.trim() || undefined + + const fallback = msg.providerID && msg.modelID ? { providerID: msg.providerID, modelID: msg.modelID } : undefined + const resolved = resolveVersionModels(msg.modelAllocations, fallback, Number(msg.versions) || 1) + const { models, versions, providerID, modelID } = resolved + + // Generate a shared group ID for multi-version worktrees + const groupId = versions > 1 ? `grp-${Date.now()}` : undefined + + host.log( + `Creating ${versions} worktrees${models.length > 0 ? " (model comparison)" : ""}${text ? ` for: ${text.slice(0, 60)}` : ""}${groupId ? ` (group=${groupId})` : ""}`, + ) + + // Notify webview that multi-version creation has started + host.post({ + type: "agentManager.multiVersionProgress", + status: "creating", + total: versions, + completed: 0, + groupId, + }) + + // Phase 1: Create all worktrees + sessions first + const created: CreatedVersion[] = [] + + for (let i = 0; i < versions; i++) { + const version = await createVersion(ctx, host, { + index: i, + versions, + groupId, + baseBranch, + branchName, + worktreeName, + models, + providerID, + modelID, + sandbox: msg.sandbox, + }) + if (!version) continue + created.push(version) + + // Update progress + host.post({ + type: "agentManager.multiVersionProgress", + status: "creating", + total: versions, + completed: created.length, + groupId, + }) + } + + // Phase 2: Send the initial prompt to all sessions, or clear busy state if no text. + await sendInitialPrompts(host, created, models, { providerID, modelID }, { text, agent, variant: msg.variant, files }) + + // Notify completion + host.post({ + type: "agentManager.multiVersionProgress", + status: "done", + total: versions, + completed: created.length, + groupId, + }) + + if (created.length === 0) { + host.error(`Failed to create any of the ${versions} multi-version worktrees.`) + } + + host.log(`Multi-version creation complete: ${created.length}/${versions} versions`) + return null +} + +interface VersionSpec { + index: number + versions: number + groupId: string | undefined + baseBranch: string | undefined + branchName: string | undefined + worktreeName: string | undefined + models: ReturnType["models"] + providerID: string | undefined + modelID: string | undefined + sandbox: boolean | undefined +} + +/** Create one version's worktree + session and wire it into state and the webview. */ +async function createVersion( + ctx: ProjectContext, + host: MultiVersionHost, + spec: VersionSpec, +): Promise { + host.log(`Creating worktree ${spec.index + 1}/${spec.versions}`) + + const version = versionedName(spec.branchName || spec.worktreeName, spec.index, spec.versions) + const wt = await host.createOnDisk({ + groupId: spec.groupId, + baseBranch: spec.baseBranch, + branchName: version.branch, + name: version.branch, + label: version.label, + }) + if (!wt) { + host.log(`Failed to create worktree for version ${spec.index + 1}`) + return null + } + + await host.runSetup(wt.result.path, wt.result.branch, wt.worktree.id) + + const session = await host.createSession(wt.result.path, wt.result.branch, wt.worktree.id) + if (!session) { + ctx.peekState()?.removeWorktree(wt.worktree.id) + await ctx.worktreeManager().removeWorktree(wt.result.path) + host.log(`Failed to create session for version ${spec.index + 1}`) + return null + } + + const state = ctx.stateManager() + state.addSession(session.id, wt.worktree.id) + if (!spec.branchName && !spec.worktreeName && host.autoName().enabled) { + state.armAutoName(wt.worktree.id, session.id) + } + + // Sandbox must match the user's choice before this session is exposed or + // receives its initial prompt. A failed reconciliation aborts this version. + if (spec.sandbox !== undefined && !(await reconcileSandbox(host, spec, wt, session.id))) return null + + host.register(session.id, wt.result.path) + host.notifyReady(session.id, wt.result, wt.worktree.id) + host.sessions.register(session) + + // Set the per-version model immediately so the UI selector reflects + // the correct model as soon as the worktree appears, before Phase 2. + // Uses a dedicated message type to avoid clearing the busy state. + const versionModel = spec.models[spec.index] + const earlyProviderID = versionModel?.providerID ?? spec.providerID + const earlyModelID = versionModel?.modelID ?? spec.modelID + if (earlyProviderID && earlyModelID) { + host.post({ + type: "agentManager.setSessionModel", + sessionId: session.id, + providerID: earlyProviderID, + modelID: earlyModelID, + }) + } + + host.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId: session.id, + worktreeId: wt.worktree.id, + branch: wt.result.branch, + multiVersion: true, + version: spec.index + 1, + totalVersions: spec.versions, + groupId: spec.groupId, + }) + host.log(`Version ${spec.index + 1} worktree ready: session=${session.id}`) + + return { + worktreeId: wt.worktree.id, + sessionId: session.id, + path: wt.result.path, + branch: wt.result.branch, + parentBranch: wt.result.parentBranch, + versionIndex: spec.index, + } +} + +/** Reconcile the sandbox preference for one version; rolls the worktree back on failure. */ +async function reconcileSandbox( + host: MultiVersionHost, + spec: VersionSpec, + wt: NonNullable>>, + sessionId: string, +): Promise { + try { + await ensureSandbox(host.client(), sessionId, wt.result.path, spec.sandbox!) + return true + } catch (error) { + const err = getErrorMessage(error) + host.log(`Failed to configure sandbox for ${sessionId}: ${err}`) + host.post({ + type: "agentManager.worktreeSetup", + status: "error", + message: `Failed to configure sandbox: ${err}`, + worktreeId: wt.worktree.id, + }) + host.capture("Agent Manager Session Error", { + source: PLATFORM, + error: err, + context: "configureSandbox", + }) + await host.discard(wt.worktree.id, wt.result.path, wt.result.branch, sessionId) + return false + } +} + +/** Fan the initial prompt out to every created session, throttled between sends. */ +async function sendInitialPrompts( + host: MultiVersionHost, + created: CreatedVersion[], + models: VersionSpec["models"], + resolved: { providerID: string | undefined; modelID: string | undefined }, + input: { + text: string | undefined + agent: string | undefined + variant: string | undefined + files: Extract["files"] + }, +): Promise { + const messages = buildInitialMessages(created, models, resolved, input.text, input.agent, input.variant, input.files) + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]! + if (input.text) { + host.log(`Sending initial message to version ${i + 1} (session=${msg.sessionId})`) + host.promptName({ + sessionID: msg.sessionId, + text: input.text, + providerID: msg.providerID, + modelID: msg.modelID, + }) + } + host.post({ type: "agentManager.sendInitialMessage", ...msg }) + if (input.text && i < messages.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 300)) + } + } +} diff --git a/packages/kilo-vscode/src/agent-manager/run/controller.ts b/packages/kilo-vscode/src/agent-manager/run/controller.ts index b5db0320e7..d0ade51e76 100644 --- a/packages/kilo-vscode/src/agent-manager/run/controller.ts +++ b/packages/kilo-vscode/src/agent-manager/run/controller.ts @@ -4,8 +4,10 @@ import { getShellEnvironment } from "../shell-env" import { RunScriptManager, type RunHandle, type RunStatus } from "./manager" import { RunScriptService } from "./service" import type { WorktreeStateManager } from "../WorktreeStateManager" +import type { RunTerminalDestination } from "./destination" export interface RunTaskConfig { + destination: RunTerminalDestination worktreeId: string branch: string command: string @@ -14,11 +16,13 @@ export interface RunTaskConfig { env: Record } -interface TaskExit { +export interface RunTaskExit { exitCode?: number + stopped?: boolean + error?: string } -type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise +export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise interface Options { root: () => string | undefined @@ -60,7 +64,7 @@ export class RunController { this.opts.refresh?.() } - async run(worktreeId: string): Promise { + async run(worktreeId: string, destination: RunTerminalDestination): Promise { const status = this.manager.status(worktreeId) if (status.state !== "idle") { this.stop(worktreeId) @@ -71,8 +75,9 @@ export class RunController { const service = this.getService() if (!root || !service) return - // Resolve cwd and branch: "local" runs from repo root, worktrees from their path - const local = worktreeId === "local" + // Resolve cwd and branch: "local" runs from repo root, worktrees from their path. + // Multi-project qualifies the local key as ":local" (see run/message.ts). + const local = worktreeId === "local" || worktreeId.endsWith(":local") const state = this.opts.state() const worktree = local ? undefined : state?.getWorktree(worktreeId) if (!local && !worktree) { @@ -109,18 +114,19 @@ export class RunController { } const start = () => - this.opts.start({ worktreeId, branch, command: script.command, args: script.args, cwd, env }, (exit) => - this.manager.finish(worktreeId, { exitCode: exit.exitCode }), + this.opts.start( + { destination, worktreeId, branch, command: script.command, args: script.args, cwd, env }, + (exit) => this.manager.finish(worktreeId, exit), ) await this.manager.start(worktreeId, start) } stop(worktreeId: string): void { - this.manager.stop(worktreeId) + void this.manager.stop(worktreeId) } - remove(worktreeId: string): void { - this.manager.remove(worktreeId) + remove(worktreeId: string): Promise { + return this.manager.remove(worktreeId) } dispose(): void { diff --git a/packages/kilo-vscode/src/agent-manager/run/destination.ts b/packages/kilo-vscode/src/agent-manager/run/destination.ts new file mode 100644 index 0000000000..5945da16b7 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/run/destination.ts @@ -0,0 +1,16 @@ +/** + * Where the Agent Manager Run button executes the project run script. + * + * The Agent Manager terminal dropdown owns this choice per panel. + * "agentManager" runs through the canonical PTY service in the embedded + * side terminal. "vscode" is the legacy integrated terminal task path, + * kept for comparison while the embedded path proves itself. Remove the + * "vscode" dropdown option, `run/task.ts`, and the integrated branch below + * together once the embedded path is the only one. + */ + +export type RunTerminalDestination = "agentManager" | "vscode" + +export function pickRunStart(destination: RunTerminalDestination, embedded: T, integrated: T): T { + return destination === "vscode" ? integrated : embedded +} diff --git a/packages/kilo-vscode/src/agent-manager/run/manager.ts b/packages/kilo-vscode/src/agent-manager/run/manager.ts index a1b92e35c9..c3984db5fe 100644 --- a/packages/kilo-vscode/src/agent-manager/run/manager.ts +++ b/packages/kilo-vscode/src/agent-manager/run/manager.ts @@ -4,6 +4,7 @@ export interface RunStatus { worktreeId: string state: RunState exitCode?: number + stopped?: boolean signal?: string startedAt?: string finishedAt?: string @@ -11,17 +12,27 @@ export interface RunStatus { } export interface RunHandle { - stop(): void + stop(): void | Promise + /** + * Kill the process but retain the terminal as failed with its output, + * instead of dropping it (stop). Used for timeouts, where the partial + * output must stay reviewable. + */ + kill?(reason: string): void dispose?(): void } interface Entry { status: RunStatus handle?: RunHandle + task?: Promise + released?: boolean + stopping?: Promise } interface FinishOptions { exitCode?: number + stopped?: boolean signal?: string error?: string } @@ -42,6 +53,7 @@ export class RunScriptManager { ) {} async start(worktreeId: string, start: () => Promise): Promise { + this.removed.delete(worktreeId) const current = this.entries.get(worktreeId) if (current && current.status.state !== "idle") return false @@ -56,21 +68,25 @@ export class RunScriptManager { this.emit(entry.status) try { - const handle = await start() + const task = start() + entry.task = task + const handle = await task const latest = this.entries.get(worktreeId) if (latest !== entry) { - handle.dispose?.() + await this.release(worktreeId, entry, handle, this.removed.has(worktreeId)) return true } entry.handle = handle - if (entry.status.state === "stopping") handle.stop() + if (entry.status.state === "stopping") { + void this.halt(worktreeId, entry, handle) + } } catch (error) { this.finish(worktreeId, { error: message(error) }) } return true } - stop(worktreeId: string): void { + async stop(worktreeId: string): Promise { const entry = this.entries.get(worktreeId) if (!entry || entry.status.state === "idle" || entry.status.state === "stopping") return @@ -81,11 +97,7 @@ export class RunScriptManager { this.emit(entry.status) if (!entry.handle) return - try { - entry.handle.stop() - } catch (error) { - this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`) - } + await this.halt(worktreeId, entry, entry.handle) } finish(worktreeId: string, opts: FinishOptions = {}): void { @@ -100,6 +112,7 @@ export class RunScriptManager { } if (entry?.status.startedAt) status.startedAt = entry.status.startedAt if (opts.exitCode !== undefined) status.exitCode = opts.exitCode + if (opts.stopped) status.stopped = true if (opts.signal) status.signal = opts.signal if (opts.error) status.error = opts.error @@ -115,24 +128,57 @@ export class RunScriptManager { return [...this.entries.values()].map((entry) => entry.status) } - remove(worktreeId: string): void { + async remove(worktreeId: string): Promise { const entry = this.entries.get(worktreeId) - if (entry?.status.state !== "idle") this.stop(worktreeId) - this.entries.delete(worktreeId) this.removed.add(worktreeId) + this.entries.delete(worktreeId) + const handle = + entry?.handle ?? + (entry?.task + ? await entry.task.catch((error) => { + this.log(`Failed to start removed run script for ${worktreeId}: ${message(error)}`) + return undefined + }) + : undefined) + if (!entry || !handle) return + if (entry.status.state !== "idle") { + await this.release(worktreeId, entry, handle, true) + return + } + await this.release(worktreeId, entry, handle, false) } dispose(): void { - for (const entry of this.entries.values()) { - if (entry.status.state !== "idle") { - try { - entry.handle?.stop() - } catch (error) { - this.log(`Failed to stop run script during dispose: ${message(error)}`) - } - } - entry.handle?.dispose?.() + for (const [id, entry] of this.entries) { + this.removed.add(id) + if (!entry.handle || entry.released) continue + entry.released = true + if (entry.status.state !== "idle") void this.halt(id, entry, entry.handle) + entry.handle.dispose?.() } this.entries.clear() } + + private async release(worktreeId: string, entry: Entry, handle: RunHandle, stop: boolean): Promise { + if (entry.released) return + entry.released = true + if (stop) await this.halt(worktreeId, entry, handle) + handle.dispose?.() + } + + private halt(worktreeId: string, entry: Entry, handle: RunHandle): Promise { + if (entry.stopping) return entry.stopping + const task = (() => { + try { + return Promise.resolve(handle.stop()) + .then(() => undefined) + .catch((error) => this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)) + } catch (error) { + this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`) + return Promise.resolve() + } + })() + entry.stopping = task + return task + } } diff --git a/packages/kilo-vscode/src/agent-manager/run/message.ts b/packages/kilo-vscode/src/agent-manager/run/message.ts index 1e8f21a936..0d55ed6005 100644 --- a/packages/kilo-vscode/src/agent-manager/run/message.ts +++ b/packages/kilo-vscode/src/agent-manager/run/message.ts @@ -1,17 +1,27 @@ import type { AgentManagerInMessage } from "../types" import type { RunController } from "./controller" -export function handleRunMessage(run: RunController, msg: AgentManagerInMessage): boolean { +/** + * The webview sends worktreeId "local" for whichever project's local context + * is selected. In multi-project mode that key would collide across projects + * in the provider-wide RunScriptManager, so the provider passes a qualifier + * that namespaces it with the owning project id. + */ +export function handleRunMessage( + run: RunController, + msg: AgentManagerInMessage, + qualify?: (worktreeId: string) => string, +): boolean { if (msg.type === "agentManager.configureRunScript") { void run.configure() return true } if (msg.type === "agentManager.runScript") { - void run.run(msg.worktreeId) + void run.run(qualify?.(msg.worktreeId) ?? msg.worktreeId, msg.destination) return true } if (msg.type === "agentManager.stopRunScript") { - run.stop(msg.worktreeId) + run.stop(qualify?.(msg.worktreeId) ?? msg.worktreeId) return true } return false diff --git a/packages/kilo-vscode/src/agent-manager/run/task.ts b/packages/kilo-vscode/src/agent-manager/run/task.ts index 0835b216af..664baf4753 100644 --- a/packages/kilo-vscode/src/agent-manager/run/task.ts +++ b/packages/kilo-vscode/src/agent-manager/run/task.ts @@ -1,3 +1,11 @@ +/** + * Legacy integrated terminal Run adapter. + * + * Kept while the Agent Manager terminal dropdown offers the "VS Code + * terminal" option so both execution paths can be compared. Remove this + * file together with that dropdown option and the integrated `pickRunStart` + * branch. + */ import * as vscode from "vscode" import type { RunHandle } from "./manager" diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts new file mode 100644 index 0000000000..9aa9c2a86f --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts @@ -0,0 +1,93 @@ +import type { KiloConnectionService } from "../services/cli-backend" +import type { OutputHandle } from "./host" +import { ScriptTerminalManager } from "./ScriptTerminalManager" +import { buildScriptTerminalWsUrl } from "./script-terminal-url" +import { readTerminalFont } from "./terminal-font" +import type { AgentManagerOutMessage } from "./types" +import type { WorktreeStateManager } from "./WorktreeStateManager" +import { RunController } from "./run/controller" +import { pickRunStart } from "./run/destination" +import { startVscodeRunTask } from "./run/task" + +interface Input { + connection: KiloConnectionService + output: OutputHandle + post(message: AgentManagerOutMessage): void +} + +export function createScriptTerminalRuntime(input: Input) { + const manager = new ScriptTerminalManager({ + getClient: () => input.connection.getClient(), + getClientAsync: (directory) => input.connection.getClientAsync(directory), + buildWsUrl: (ptyID, cwd) => { + const config = input.connection.getServerConfig() + if (!config) throw new Error("Not connected to CLI backend") + return buildScriptTerminalWsUrl(config, ptyID, cwd) + }, + getTerminalFont: () => readTerminalFont(), + emit: (terminals) => input.post({ type: "agentManager.scriptTerminals", terminals }), + closed: (terminalId) => input.post({ type: "agentManager.terminal.closed", terminalId }), + log: (msg) => input.output.appendLine(`[RunScript] ${msg}`), + }) + const event = input.connection.onEventFiltered( + (value) => (value.type === "pty.exited" || value.type === "pty.deleted") && manager.owns(value.properties.id), + (value) => { + if (value.type === "pty.exited") manager.exited(value.properties.id, value.properties.exitCode) + if (value.type === "pty.deleted") manager.deleted(value.properties.id) + }, + ) + const connection = input.connection.onStateChange((state) => { + if (state === "connected") void manager.sync() + }) + return { + manager, + dispose: async () => { + event() + connection() + await manager.dispose() + }, + } +} + +interface RunInput { + manager: ScriptTerminalManager + root(): string | undefined + state(): WorktreeStateManager | undefined + project?(worktreeId: string): string | undefined + open(path: string): Promise + trusted(): boolean + post(message: AgentManagerOutMessage): void + log(message: string): void + refresh(): void +} + +/** Stop and remove any Run/Setup script terminals owned by a worktree. */ +export async function clearScriptTerminals( + manager: ScriptTerminalManager, + worktreeId: string, + projectId?: string, +): Promise { + const run = await manager.clear("run", worktreeId, projectId) + const setup = await manager.clear("setup", worktreeId, projectId) + return run && setup +} + +export function createRunController(input: RunInput) { + return new RunController({ + root: input.root, + state: input.state, + open: input.open, + start: async (config, done) => { + if (!input.trusted()) throw new Error("Trust the workspace before running scripts") + return pickRunStart( + config.destination, + (cfg, cb) => input.manager.start("run", { ...cfg, projectId: input.project?.(cfg.worktreeId) }, cb), + startVscodeRunTask, + )(config, done) + }, + post: (status) => input.post({ type: "agentManager.runStatus", ...status }), + error: (message) => input.post({ type: "error", message }), + log: input.log, + refresh: input.refresh, + }) +} diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts new file mode 100644 index 0000000000..8151b460ee --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts @@ -0,0 +1,17 @@ +export interface PtyServerConfig { + baseUrl: string + password: string +} + +/** Build the canonical authenticated PTY WebSocket URL for script terminals. */ +export function buildScriptTerminalWsUrl(config: PtyServerConfig, ptyID: string, cwd: string): string { + const base = config.baseUrl.replace(/^http/i, "ws").replace(/\/$/, "") + const token = Buffer.from(`kilo:${config.password}`).toString("base64") + const query = new URLSearchParams({ + "location[directory]": cwd, + cursor: "0", + replayExited: "1", + auth_token: token, + }) + return `${base}/api/pty/${encodeURIComponent(ptyID)}/connect?${query.toString()}` +} diff --git a/packages/kilo-vscode/src/agent-manager/section-handler.ts b/packages/kilo-vscode/src/agent-manager/section-handler.ts index 624a23eb10..01bbd0c6cd 100644 --- a/packages/kilo-vscode/src/agent-manager/section-handler.ts +++ b/packages/kilo-vscode/src/agent-manager/section-handler.ts @@ -1,13 +1,27 @@ import type { WorktreeStateManager } from "./WorktreeStateManager" import type { AgentManagerInMessage } from "./types" +const SECTION_TYPES = new Set([ + "agentManager.createSection", + "agentManager.renameSection", + "agentManager.deleteSection", + "agentManager.setSectionColor", + "agentManager.toggleSectionCollapsed", + "agentManager.moveToSection", + "agentManager.moveSection", +]) + /** Handle section CRUD messages. Returns true if handled. */ export function handleSection( state: WorktreeStateManager | undefined, m: AgentManagerInMessage, push: () => void, + log?: (...args: unknown[]) => void, ): boolean { - if (!state) return false + if (!state) { + if (SECTION_TYPES.has(m.type)) log?.(`handleSection: ${m.type} dropped, state missing`) + return false + } if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds) else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name) else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId) diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-task.ts b/packages/kilo-vscode/src/agent-manager/setup-script-task.ts new file mode 100644 index 0000000000..f001955976 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-task.ts @@ -0,0 +1,176 @@ +/** + * Embedded setup-script execution through the canonical PTY script-terminal + * runtime. Selected by the Agent Manager terminal destination; the VS Code + * task runner (task-runner.ts) remains the integrated-terminal path. Remove + * the integrated path together with the "VS Code terminal" dropdown option + * once the embedded path is the only one. + * + * Exit-code semantics mirror task-runner.ts: resolve with the exit code + * (undefined when unknown), reject on execution errors and timeout. The + * SetupScriptRunner treats every outcome as best-effort, so worktree + * creation continues even when the script fails. + */ + +import { getShellEnvironment } from "./shell-env" +import { SetupScriptRunner, type RunTask } from "./SetupScriptRunner" +import type { SetupScriptService } from "./SetupScriptService" +import type { RunHandle } from "./run/manager" +import type { ScriptTerminalManager } from "./ScriptTerminalManager" +import type { TerminalDestination } from "./terminal-destination" +import type { AgentManagerOutMessage } from "./types" + +const TIMEOUT_MS = 5 * 60 * 1000 + +/** Reconcile cadence while a script runs: a lost exit event otherwise + * stalls the awaited setup (and its tab) until the full timeout. */ +const WATCHDOG_MS = 15_000 + +interface Input { + manager: ScriptTerminalManager + projectId?: string + worktreeId: string + trusted(): boolean + log(msg: string): void + /** Test hook; defaults to the same five minutes as task-runner.ts. */ + timeoutMs?: number + /** Test hook; defaults to a 15s reconcile cadence while the script runs. */ + watchdogMs?: number + /** Test hook; defaults to the login-shell (posix) / extension-host (win32) environment. */ + env?: () => Promise> +} + +interface PickInput extends Omit { + destination: TerminalDestination + worktreeId: string | undefined + /** Integrated-terminal task runner, used whenever the embedded path is off. */ + vscode: RunTask +} + +/** + * Pick where a worktree setup script executes. The terminal destination + * dropdown owns the choice. The embedded path enforces workspace trust + * itself; it never silently redirects a selected destination to VS Code. + */ +export function pickSetupTask(input: PickInput): RunTask { + const worktreeId = input.worktreeId + if (input.destination !== "agentManager" || !worktreeId) return input.vscode + return createSetupScriptTask({ + manager: input.manager, + projectId: input.projectId, + worktreeId, + trusted: input.trusted, + log: input.log, + timeoutMs: input.timeoutMs, + env: input.env, + }) +} + +interface FlowInput extends PickInput { + service: SetupScriptService | undefined + branch?: string + post(message: AgentManagerOutMessage): void +} + +/** Run the configured setup script for a worktree and wait for it to finish. */ +export async function runWorktreeSetupScript( + input: FlowInput, + env: { worktreePath: string; repoPath: string }, +): Promise { + const service = input.service + if (!service || !service.hasScript()) return + input.post({ + type: "agentManager.worktreeSetup", + projectId: input.projectId, + status: "creating", + message: "Running setup script...", + branch: input.branch, + worktreeId: input.worktreeId, + }) + const runner = new SetupScriptRunner(input.log, service, pickSetupTask(input), (message) => { + if (message === "Setup script was stopped") return + input.post({ + type: "agentManager.worktreeSetup", + projectId: input.projectId, + status: "error", + message, + branch: input.branch, + worktreeId: input.worktreeId, + }) + }) + await runner.runIfConfigured(env) +} + +export function createSetupScriptTask(input: Input): RunTask { + return async (config) => { + if (!input.trusted()) throw new Error("Trust the workspace before running setup scripts") + const env = { ...(await (input.env ?? getShellEnvironment)()), ...config.env } + const ms = input.timeoutMs ?? TIMEOUT_MS + return new Promise((resolve, reject) => { + let handle: RunHandle | undefined + let settled = false + let expired = false + const settle = (action: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + clearInterval(watchdog) + action() + } + const halt = (target: RunHandle, reason: string) => { + // Keep the tab with its partial output when the handle can kill; + // only fall back to dropping it for handles without kill support. + if (target.kill) { + target.kill(reason) + return + } + void Promise.resolve(target.stop()).catch((error) => { + input.log(`Failed to stop Setup terminal: ${error instanceof Error ? error.message : String(error)}`) + }) + } + const expire = () => { + expired = true + settle(() => reject(new Error("Setup script timed out after 5 minutes"))) + if (handle) halt(handle, "Setup script timed out after 5 minutes") + } + // The first budget covers connect + create; once the PTY is running + // the script itself gets a fresh full budget, so a slow backend start + // never eats into its five minutes. + let timer = setTimeout(expire, ms) + // Exit events are the primary signal; reconcile periodically so a + // lost event cannot leave the script (and worktree creation) stuck. + const watchdog = setInterval(() => { + void input.manager.sync().catch((error) => { + input.log(`Setup terminal reconcile failed: ${error instanceof Error ? error.message : String(error)}`) + }) + }, input.watchdogMs ?? WATCHDOG_MS) + + input.manager + .start("setup", { ...config, env, projectId: input.projectId, worktreeId: input.worktreeId }, (exit) => { + if (exit.error) { + settle(() => reject(new Error(exit.error))) + return + } + if (exit.stopped) { + settle(() => reject(new Error("Setup script was stopped"))) + return + } + settle(() => resolve(exit.exitCode)) + }) + .then( + (created) => { + handle = created + if (settled) { + // Only an expired timer may kill a late handle. A natural + // settle (fast exit observed during creation) must keep the + // exited terminal and its retained output alive. + if (expired) halt(created, "Setup script timed out after 5 minutes") + return + } + clearTimeout(timer) + timer = setTimeout(expire, ms) + }, + (error) => settle(() => reject(error instanceof Error ? error : new Error(String(error)))), + ) + }) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/shell-env.ts b/packages/kilo-vscode/src/agent-manager/shell-env.ts index 1b6e61d7f1..9644f65d2f 100644 --- a/packages/kilo-vscode/src/agent-manager/shell-env.ts +++ b/packages/kilo-vscode/src/agent-manager/shell-env.ts @@ -61,6 +61,15 @@ function parseEnvOutput(stdout: string): Record { * Results are cached for 1 minute (10 seconds when the fallback was used). */ export async function getShellEnvironment(): Promise> { + // Windows has no login shell to resolve; the extension-host environment + // already is the user's environment. + if (process.platform === "win32") { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") env[key] = value + } + return env + } const now = Date.now() const ttl = wasFallback ? FALLBACK_TTL : TTL if (cached && now - cacheTime < ttl) return { ...cached } diff --git a/packages/kilo-vscode/src/agent-manager/terminal-destination.ts b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts index f6fbceb591..fc334b7c6b 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-destination.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts @@ -24,6 +24,45 @@ export function readTerminalDestination(): TerminalDestination { return resolveTerminalDestination(config.get("terminalButtonDestination")) } +async function writeTerminalDestination(destination: TerminalDestination): Promise { + const config = vscode.workspace.getConfiguration("kilo-code.new.agentManager") + await config.update("terminalButtonDestination", destination, vscode.ConfigurationTarget.Global) +} + +/** Per-panel destination source of truth; local choices beat setting echoes. */ +export class DestinationState { + private local = false + + constructor(private destination = readTerminalDestination()) {} + + value(): TerminalDestination { + return this.destination + } + + sync(destination: TerminalDestination): void { + if (!this.local) this.destination = destination + } + + select(destination: TerminalDestination): void { + this.local = true + this.destination = destination + } +} + +export function handleDestination( + state: DestinationState, + message: { type: string; destination?: unknown }, + log: (message: string) => void, +): boolean { + if (message.type !== "agentManager.terminal.destinationSelected") return false + const destination = resolveTerminalDestination(message.destination) + state.select(destination) + void writeTerminalDestination(destination).catch((error) => { + log(`Failed to persist terminal destination: ${error instanceof Error ? error.message : String(error)}`) + }) + return true +} + export function affectsTerminalDestination(e: vscode.ConfigurationChangeEvent): boolean { return e.affectsConfiguration(KEY) } diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index c1662d92b3..9f3d030728 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -113,6 +113,16 @@ export class TerminalManager { } } + /** Titles of every live terminal in a context — used by the router to + * pick the lowest free "Terminal N" ordinal. */ + titles(worktreeId: string | null): string[] { + const out: string[] = [] + for (const entry of this.entries.values()) { + if (entry.worktreeId === worktreeId) out.push(entry.title) + } + return out + } + /** Kill a single terminal. Best-effort — we always drop our bookkeeping. * The SDK's `pty.remove` returns `{ data, error }` without throwing * on 4xx/5xx, so we have to check `error` ourselves; otherwise a diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 566952f9b2..0de5399600 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -26,6 +26,8 @@ interface ServerConfig { export interface TerminalRoutingDeps { /** Shared SDK client. Throws when the CLI backend is not connected. */ getClient(): KiloClient + /** Shared SDK client, connecting the CLI backend when needed. */ + getClientAsync(): Promise /** Loopback URL + basic-auth password for the running `kilo serve`. */ getServerConfig(): ServerConfig | undefined /** Workspace root — used as cwd fallback when no worktree is selected (LOCAL). */ @@ -43,7 +45,10 @@ export interface TerminalRoutingDeps { /** True iff the message belongs to the terminal-tab subsystem. */ function isTerminalMessage( m: AgentManagerInMessage, -): m is Extract { +): m is Exclude< + Extract, + { type: "agentManager.terminal.stop" | "agentManager.terminal.destinationSelected" } +> { return ( m.type === "agentManager.terminal.create" || m.type === "agentManager.terminal.close" || @@ -53,7 +58,9 @@ function isTerminalMessage( export class TerminalRouter { private manager: TerminalManager - private readonly ordinals = new Map() + /** Ordinals reserved by in-flight creates, per context — prevents two + * concurrent creates from grabbing the same "Terminal N" title. */ + private readonly reserved = new Map>() private generation = 0 constructor(private readonly deps: TerminalRoutingDeps) { @@ -100,6 +107,7 @@ export class TerminalRouter { this.generation++ const manager = this.manager this.manager = this.createManager() + this.reserved.clear() return manager.dispose() } @@ -117,8 +125,12 @@ export class TerminalRouter { }) return } - const title = `Terminal ${this.nextOrdinal(worktreeId)}` + const ordinal = this.reserveOrdinal(worktreeId) + const title = `Terminal ${ordinal}` try { + // Join the shared backend connection instead of racing its synchronous + // client accessor when this is the first Kilo action in the window. + await this.deps.getClientAsync() const created = await manager.create({ worktreeId, cwd, title }) if (generation !== this.generation) { await manager.close(created.terminalId) @@ -139,6 +151,11 @@ export class TerminalRouter { const message = err instanceof Error ? err.message : String(err) this.deps.log(`Terminal create failed: ${message}`) this.deps.post({ type: "agentManager.terminal.error", createId, message }) + } finally { + // Only a current-generation create may release: dispose() already + // cleared this create's reservation, and releasing here would + // delete a *new* panel's reservation for the same number. + if (generation === this.generation) this.releaseOrdinal(worktreeId, ordinal) } } @@ -156,15 +173,40 @@ export class TerminalRouter { return this.deps.getWorktreePath(worktreeId) } - /** Per-context counter so default titles are "Terminal 1", "Terminal 2"… - * Not persisted; a webview reload resets counts. */ - private nextOrdinal(worktreeId: string | null): number { + /** + * Pick the lowest "Terminal N" ordinal not used by a live terminal or + * an in-flight create in this context, and reserve it until the + * create settles. Gap-filling keeps numbering consistent: closing + * "Terminal 1" of three frees 1 for the next terminal, instead of + * drifting to ever-higher numbers. Not persisted; a webview reload + * resets the live set. + */ + private reserveOrdinal(worktreeId: string | null): number { const key = worktreeId ?? "__local__" - const next = (this.ordinals.get(key) ?? 0) + 1 - this.ordinals.set(key, next) + const used = new Set() + for (const title of this.manager.titles(worktreeId)) { + const match = /^Terminal (\d+)$/.exec(title) + if (match) used.add(Number(match[1])) + } + const pending = this.reserved.get(key) + if (pending) for (const n of pending) used.add(n) + let next = 1 + while (used.has(next)) next++ + const set = pending ?? new Set() + set.add(next) + this.reserved.set(key, set) return next } + /** Return an in-flight create's reservation. */ + private releaseOrdinal(worktreeId: string | null, ordinal: number) { + const key = worktreeId ?? "__local__" + const set = this.reserved.get(key) + if (!set) return + set.delete(ordinal) + if (set.size === 0) this.reserved.delete(key) + } + /** * Build the WebSocket URL for a given PTY. * @@ -182,6 +224,9 @@ export class TerminalRouter { const token = Buffer.from(`kilo:${config.password}`).toString("base64") const dir = encodeURIComponent(cwd) const auth = encodeURIComponent(token) - return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=-1&auth_token=${auth}` + // A new terminal has one initial attachment. Replay its retained startup + // bytes so xterm can answer shell capability queries emitted before the + // WebSocket connected; tailing from -1 can make shells wait for a timeout. + return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=0&auth_token=${auth}` } } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8950c68d22..6e26186d4d 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -15,9 +15,13 @@ import type { ApplyConflict } from "./GitOps" import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" import type { RunStatus } from "./run/manager" import type { TerminalFont } from "./terminal-font" +import type { ProjectSnapshot } from "./project/contexts" +import type { SidebarTarget } from "./project/route" import type { TerminalDestination } from "./terminal-destination" +import type { ScriptTerminalView } from "./ScriptTerminalManager" export type { TerminalFont } +export type { ProjectSnapshot } /** Where a terminal lives: main tab strip or right-side inspector panel. */ export type TerminalPlacement = "tab" | "side" @@ -97,16 +101,22 @@ export interface PRStatus { interface WorktreeStatsMessage { type: "agentManager.worktreeStats" + /** Owning project; absent in single-project mode. */ + projectId?: string stats: WorktreeStats[] } interface LocalStatsMessage { type: "agentManager.localStats" + /** Owning project; absent in single-project mode. */ + projectId?: string stats: LocalStats } interface WorktreeSetupMessage { type: "agentManager.worktreeSetup" + /** Owning project; absent in single-project mode. */ + projectId?: string status: "creating" | "starting" | "ready" | "error" message: string sessionId?: string @@ -132,9 +142,41 @@ interface StateMessage { runStatuses?: RunStatus[] runScriptConfigured?: boolean runScriptPath?: string + /** Owning project for this state payload. Absent in legacy single-project payloads. */ + projectId?: string + /** Last selected sidebar target for seamless project-switch restore. */ + activeTarget?: SidebarTarget terminalDestination?: TerminalDestination } +/** Project catalog pushed to the webview after registry or context changes. */ +interface ProjectsMessage { + type: "agentManager.projects" + /** Whether the multi-project experiment is enabled. */ + multiProject: boolean + projects: ProjectSnapshot[] +} + +interface SelectionActivatedMessage { + type: "agentManager.selectionActivated" + target: SidebarTarget +} + +interface ProjectSessionsMessage { + type: "agentManager.projectSessions" + projectId: string + sessions: Array<{ + id: string + parentID?: string | null + title?: string + createdAt: string + updatedAt: string + worktreeId: string | null + revert?: unknown + summary?: unknown + }> +} + // --------------------------------------------------------------------------- // Terminal messages // --------------------------------------------------------------------------- @@ -177,6 +219,11 @@ interface TerminalFontChangedMessage { font: TerminalFont } +interface ScriptTerminalsMessage { + type: "agentManager.scriptTerminals" + terminals: ScriptTerminalView[] +} + interface ErrorOutMessage { type: "error" message: string @@ -229,6 +276,7 @@ interface SendInitialMessage { interface BranchesMessage { type: "agentManager.branches" + projectId?: string branches: (BranchListItem & { isCheckedOut?: boolean })[] defaultBranch: string } @@ -265,6 +313,13 @@ interface WorktreeDiffLoadingMessage { loading: boolean } +/** Source-level notice for a diff context (e.g. snapshots disabled). */ +interface WorktreeDiffNoticeMessage { + type: "agentManager.worktreeDiffNotice" + sessionId: string + notice?: string +} + interface WorktreeDiffMessage { type: "agentManager.worktreeDiff" sessionId: string @@ -286,8 +341,22 @@ interface RevertWorktreeFileResultMessage { message: string } +/** Branch picker data for a context's diff directory. */ +interface DiffBranchesMessage { + type: "agentManager.diffBranches" + sessionId: string + branches: BranchListItem[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string +} + interface PRStatusOutMessage { type: "agentManager.prStatus" + /** Owning project; absent in single-project mode. */ + projectId?: string worktreeId: string pr: PRStatus | null error?: "gh_missing" | "gh_auth" | "fetch_failed" @@ -300,6 +369,8 @@ interface ActionOutMessage { interface RunStatusMessage extends RunStatus { type: "agentManager.runStatus" + /** Owning project for this status. Absent in legacy single-project mode. */ + projectId?: string } /** All messages the Agent Manager extension sends to the webview. */ @@ -308,6 +379,9 @@ export type AgentManagerOutMessage = | LocalStatsMessage | WorktreeSetupMessage | StateMessage + | ProjectsMessage + | SelectionActivatedMessage + | ProjectSessionsMessage | ErrorOutMessage | SessionAddedMessage | SessionForkedMessage @@ -321,9 +395,11 @@ export type AgentManagerOutMessage = | RepoInfoMessage | ApplyWorktreeDiffResultMessage | WorktreeDiffLoadingMessage + | WorktreeDiffNoticeMessage | WorktreeDiffMessage | WorktreeDiffFileMessage | RevertWorktreeFileResultMessage + | DiffBranchesMessage | PRStatusOutMessage | ActionOutMessage | RunStatusMessage @@ -332,6 +408,7 @@ export type AgentManagerOutMessage = | TerminalErrorMessage | TerminalDestinationChangedMessage | TerminalFontChangedMessage + | ScriptTerminalsMessage // --------------------------------------------------------------------------- // Webview → Extension messages (onMessage) @@ -341,25 +418,80 @@ interface CreateWorktreeIn { type: "agentManager.createWorktree" baseBranch?: string branchName?: string + /** Target project. Must match the active project when present; mismatches are dropped. */ + projectId?: string +} + +/** Request the current project catalog. */ +interface RequestProjectsIn { + type: "agentManager.requestProjects" +} + +/** Add a repository as a project via the host folder picker. */ +interface AddProjectIn { + type: "agentManager.addProject" +} + +/** Remove a project from the catalog. Never deletes repository data. */ +interface RemoveProjectIn { + type: "agentManager.removeProject" + projectId: string +} + +/** Make a project the active context. */ +interface SelectProjectIn { + type: "agentManager.selectProject" + projectId: string +} + +interface ActivateSelectionIn { + type: "agentManager.activateSelection" + target: SidebarTarget + /** Resolve the project's persisted target instead of using `target` verbatim. */ + restore?: boolean +} + +/** Persist the webview's current selection for seamless restore after switching back. */ +interface RememberTargetIn { + type: "agentManager.rememberTarget" + projectId: string + target: SidebarTarget +} + +/** Expand or collapse a project accordion without changing the active project. */ +interface SetProjectExpandedIn { + type: "agentManager.setProjectExpanded" + projectId: string + expanded: boolean +} + +/** Grant a project permission to run project-controlled scripts and load state. */ +interface TrustProjectIn { + type: "agentManager.trustProject" + projectId: string } interface DeleteWorktreeIn { type: "agentManager.deleteWorktree" + projectId?: string worktreeId: string } interface RemoveStaleWorktreeIn { type: "agentManager.removeStaleWorktree" + projectId?: string worktreeId: string } interface PromoteSessionIn { type: "agentManager.promoteSession" + projectId?: string sessionId: string } interface OpenLocallyIn { type: "agentManager.openLocally" + projectId?: string sessionId: string } @@ -389,15 +521,19 @@ interface ForgetSessionIn { interface ConfigureSetupScriptIn { type: "agentManager.configureSetupScript" + projectId?: string } interface ConfigureRunScriptIn { type: "agentManager.configureRunScript" + projectId?: string } interface RunScriptIn { type: "agentManager.runScript" + projectId?: string worktreeId: string + destination: TerminalDestination } interface StopRunScriptIn { @@ -414,8 +550,14 @@ interface ShowLocalTerminalIn { type: "agentManager.showLocalTerminal" } +interface ShowWorktreeTerminalIn { + type: "agentManager.showWorktreeTerminal" + worktreeId: string +} + interface OpenWorktreeIn { type: "agentManager.openWorktree" + projectId?: string worktreeId: string } @@ -434,6 +576,7 @@ interface RequestRepoInfoIn { interface CreateMultiVersionIn { type: "agentManager.createMultiVersion" + projectId?: string text?: string name?: string versions?: number @@ -451,6 +594,7 @@ interface CreateMultiVersionIn { interface RenameWorktreeIn { type: "agentManager.renameWorktree" + projectId?: string worktreeId: string label: string } @@ -461,6 +605,7 @@ interface RequestStateIn { interface RequestBranchesIn { type: "agentManager.requestBranches" + projectId?: string } interface SetTabOrderIn { @@ -476,6 +621,7 @@ interface SetWorktreeOrderIn { interface SetSessionsCollapsedIn { type: "agentManager.setSessionsCollapsed" + projectId?: string collapsed: boolean } @@ -496,22 +642,26 @@ interface SetReviewMarkdownRenderIn { interface SetDefaultBaseBranchIn { type: "agentManager.setDefaultBaseBranch" + projectId?: string branch?: string } interface ImportFromBranchIn { type: "agentManager.importFromBranch" + projectId?: string branch: string } interface ImportFromPRIn { type: "agentManager.importFromPR" + projectId?: string url: string } interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" sessionId: string + scope?: string } interface ApplyWorktreeDiffIn { @@ -524,11 +674,17 @@ interface RequestWorktreeDiffFileIn { type: "agentManager.requestWorktreeDiffFile" sessionId: string file: string + scope?: string + /** Active session for the session scope (ctx alone is a worktree/local id). */ + diffSessionId?: string } interface StartDiffWatchIn { type: "agentManager.startDiffWatch" sessionId: string + scope?: string + /** Active session for the session scope (ctx alone is a worktree/local id). */ + diffSessionId?: string } interface StopDiffWatchIn { @@ -539,6 +695,20 @@ interface RevertWorktreeFileIn { type: "agentManager.revertWorktreeFile" sessionId: string file: string + scope?: string +} + +interface RequestDiffBranchesIn { + type: "agentManager.requestDiffBranches" + sessionId: string + scope?: string +} + +interface SetDiffBaseBranchIn { + type: "agentManager.setDiffBaseBranch" + sessionId: string + scope?: string + branch?: string } interface RefreshPRIn { @@ -548,6 +718,7 @@ interface RefreshPRIn { interface OpenPRIn { type: "agentManager.openPR" + projectId?: string worktreeId: string } @@ -698,6 +869,7 @@ interface ContinueInWorktreeIn { interface CreateSectionIn { type: "agentManager.createSection" + projectId?: string name: string color?: string worktreeIds?: string[] @@ -705,34 +877,40 @@ interface CreateSectionIn { interface RenameSectionIn { type: "agentManager.renameSection" + projectId?: string sectionId: string name: string } interface DeleteSectionIn { type: "agentManager.deleteSection" + projectId?: string sectionId: string } interface SetSectionColorIn { type: "agentManager.setSectionColor" + projectId?: string sectionId: string color: string | null } interface ToggleSectionCollapsedIn { type: "agentManager.toggleSectionCollapsed" + projectId?: string sectionId: string } interface MoveToSectionIn { type: "agentManager.moveToSection" + projectId?: string worktreeIds: string[] sectionId: string | null } interface MoveSectionIn { type: "agentManager.moveSection" + projectId?: string sectionId: string dir: -1 | 1 } @@ -755,6 +933,11 @@ interface TerminalCloseIn { terminalId: string } +interface TerminalStopIn { + type: "agentManager.terminal.stop" + terminalId: string +} + interface TerminalResizeIn { type: "agentManager.terminal.resize" terminalId: string @@ -762,9 +945,22 @@ interface TerminalResizeIn { rows: number } +interface TerminalDestinationSelectedIn { + type: "agentManager.terminal.destinationSelected" + destination: TerminalDestination +} + /** All messages the Agent Manager expects from the webview (onMessage input). */ export type AgentManagerInMessage = | CreateWorktreeIn + | RequestProjectsIn + | AddProjectIn + | RemoveProjectIn + | SelectProjectIn + | ActivateSelectionIn + | RememberTargetIn + | SetProjectExpandedIn + | TrustProjectIn | DeleteWorktreeIn | RemoveStaleWorktreeIn | PromoteSessionIn @@ -780,6 +976,7 @@ export type AgentManagerInMessage = | StopRunScriptIn | ShowTerminalIn | ShowLocalTerminalIn + | ShowWorktreeTerminalIn | OpenWorktreeIn | CopyToClipboardIn | ShowExistingLocalTerminalIn @@ -803,6 +1000,8 @@ export type AgentManagerInMessage = | StartDiffWatchIn | StopDiffWatchIn | RevertWorktreeFileIn + | RequestDiffBranchesIn + | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn | OpenSessionsIn @@ -831,4 +1030,6 @@ export type AgentManagerInMessage = | MoveSectionIn | TerminalCreateIn | TerminalCloseIn + | TerminalStopIn | TerminalResizeIn + | TerminalDestinationSelectedIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index fc5dfd0107..4e1528f8a9 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -6,7 +6,9 @@ */ import * as vscode from "vscode" -import type { Host, PanelContext, OutputHandle, SessionProvider } from "./host" +import type { Session } from "@kilocode/sdk/v2/client" +import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host" +import { ProjectRouteService } from "./project/route" import type { KiloConnectionService } from "../services/cli-backend" import { KiloProvider } from "../KiloProvider" import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" @@ -20,6 +22,12 @@ import type { RemoteStatusService } from "../services/RemoteStatusService" export class VscodeHost implements Host { private diffVirtual: DiffVirtualProvider | undefined private autoApprove: AutoApproveController | undefined + /** + * Shared project route registry for every Agent Manager panel opened by + * this host. One service keeps raw session id ambiguity consistent across + * panels, so two panels never disagree about whether an id is ambiguous. + */ + private readonly routes = new ProjectRouteService() constructor( private readonly extensionUri: vscode.Uri, @@ -59,6 +67,8 @@ export class VscodeHost implements Host { opts: { onBeforeMessage: (msg: Record) => Promise | null> worktreeDirectories?: () => string[] + workspaceRoot?: () => string | undefined + projectId?: () => string | undefined }, ): PanelContext { return this.wirePanel(panel, opts) @@ -69,6 +79,8 @@ export class VscodeHost implements Host { opts: { onBeforeMessage: (msg: Record) => Promise | null> worktreeDirectories?: () => string[] + workspaceRoot?: () => string | undefined + projectId?: () => string | undefined }, ): PanelContext { panel.webview.options = { @@ -96,7 +108,13 @@ export class VscodeHost implements Host { snapshotInitialization: SNAPSHOT_INITIALIZATION, slimEditMetadata: true, worktreeDirectories: () => opts.worktreeDirectories?.() ?? [], + rootDirectory: opts.workspaceRoot, disableViewedRegistration: true, + routeService: this.routes, + projectQualifier: () => { + const projectId = opts.projectId?.() + return projectId ? { projectId } : undefined + }, }) if (this.diffVirtual) { provider.setDiffVirtualProvider(this.diffVirtual) @@ -116,6 +134,7 @@ export class VscodeHost implements Host { clearSessionDirectory: (id) => provider.clearSessionDirectory(id), getSessionDirectories: () => provider.getSessionDirectories(), getSessionInfo: (id) => provider.getSessionInfo(id), + listSessions: (dir) => this.listProjectSessions(dir), trackSession: (id) => provider.trackSession(id), refreshSessions: () => provider.refreshSessions(), registerSession: (s) => provider.registerSession(s), @@ -125,6 +144,13 @@ export class VscodeHost implements Host { abortSessions: (ids) => provider.abortSessions(ids), showMemory: (id) => provider.showMemory(id), toggleMemory: (id) => provider.toggleMemory(id), + registerProjectRoute: (ref, root, generation) => provider.registerProjectRoute(ref, root, generation), + unregisterProjectRoute: (projectId) => provider.unregisterProjectRoute(projectId), + registerWorktreeRoute: (ref, directory, generation) => provider.registerWorktreeRoute(ref, directory, generation), + registerSessionRoute: (ref, directory, generation) => provider.registerSessionRoute(ref, directory, generation), + unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref), + isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId), + routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref), dispose: () => provider.dispose(), } @@ -169,10 +195,70 @@ export class VscodeHost implements Host { } } + /** + * List root sessions for one project directory via the shared CLI backend. + * Used by per-project session discovery so multi-project Agent Manager lists + * real Local/history sessions by their exact directory instead of only the + * persisted managed records. Returns [] when the backend is not connected or + * the listing fails, so one directory's failure cannot erase another's + * results. + */ + private async listProjectSessions(dir: string): Promise { + try { + const client = await this.connectionService.getClientAsync(dir) + const res = await client.session.list({ directory: dir, roots: true }, { throwOnError: true }) + return res.data + } catch (err) { + console.warn(`[Kilo New] Agent Manager: failed to list project sessions for ${dir}:`, err) + return [] + } + } + workspacePath(): string | undefined { return getWorkspaceRoot() } + async pickFolder(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: "Add Project", + title: "Add Project to Agent Manager", + }) + return uris?.[0]?.fsPath + } + + multiProject(): boolean { + return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false) + } + + readProjects(): unknown { + return this.context.globalState.get("agentManager.projects") + } + + async writeProjects(value: unknown): Promise { + await this.context.globalState.update("agentManager.projects", value) + } + + unregisterProjectRoutes(projectId: string): void { + this.routes.unregisterProject(projectId) + } + + onDidChangeWorkspaceFolders(cb: () => void): Disposable { + return vscode.workspace.onDidChangeWorkspaceFolders(() => cb()) + } + + onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("kilo-code.new.experimental.multiProject")) cb(this.multiProject()) + }) + } + + isTrusted(): boolean { + return vscode.workspace.isTrusted + } + autoBranchNaming(): { enabled: boolean; prefix: string } { const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager") return { diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 75d538cc35..b8a4b269e7 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,12 +1,13 @@ import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" -import type { DiffFile } from "../diff/types" -import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" +import type { DiffFile, PanelContext } from "../diff/types" +import type { DiffSource } from "../diff/sources/types" +import type { DiffSourceCatalog } from "../diff/sources/catalog" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" -import { Semaphore } from "./semaphore" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" +import { parseDiffId, scopeToSourceId } from "./diff-scope" import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const @@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined - /** - * In-process diff paths deliberately bypass the SDK client to keep git spawns - * out of the Bun `kilo serve` process (see oven-sh/bun#18265). - */ + /** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */ + catalog: DiffSourceCatalog + /** Shared git ops, injected into sources so they don't spawn their own channels. */ git: GitOps - /** In-process diff summary (replaces client.worktree.diffSummary). */ - localDiff: (dir: string, base: string) => Promise - /** In-process single-file diff (replaces client.worktree.diffFile). */ + /** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */ localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void @@ -34,13 +32,16 @@ export interface WorktreeDiffControllerContext { export class WorktreeDiffController { private readonly controller: SourceController - private readonly details = new Semaphore(3) private target: Target | undefined private applying: string | undefined + /** Intended watch mode for the active context; isPolling lags the initial fetch. */ + private poll = false + /** Ephemeral per-context base override, keyed by context id. */ + private baseOverrides = new Map() constructor(private readonly ctx: WorktreeDiffControllerContext) { this.controller = new SourceController( - (id) => this.source(id), + (id, ctx) => this.source(id, ctx), () => [], (msg) => this.ctx.post(msg as AgentManagerOutMessage), { @@ -49,6 +50,11 @@ export class WorktreeDiffController { sessionId: source.descriptor.id, loading, }), + notice: (source, notice) => ({ + type: "agentManager.worktreeDiffNotice", + sessionId: source.descriptor.id, + notice, + }), diffs: (source, diffs) => ({ type: "agentManager.worktreeDiff", sessionId: source.descriptor.id, @@ -80,7 +86,11 @@ export class WorktreeDiffController { } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) + // The parsed context id is a worktree id (or `local`), so the + // orphaned-session check matches sessions of the deleted worktree. + const current = this.controller.currentId + const ctxId = current ? parseDiffId(current).ctx : undefined + return shouldStopDiffPolling(path, sessions, this.target, ctxId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -144,131 +154,155 @@ export class WorktreeDiffController { } } - public async revert(sessionId: string, file: string): Promise { + public async revert(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - const result = await this.revertFile(sessionId, file) - this.postRevertResult(sessionId, file, result) + if (this.controller.currentId !== id) { + const result = await this.revertFile(id, file) + this.postRevertResult(id, file, result) return } await this.controller.revertFile(file) } - public async request(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) { - await this.activate(sessionId, false, true) + public async request(id: string): Promise { + if (this.controller.currentId !== id) { + await this.activate(id, false, true) return } this.target = undefined await this.controller.refresh() } - public async requestFile(sessionId: string, file: string): Promise { + public async requestFile(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + if (this.controller.currentId !== id) { + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null }) return } await this.controller.requestFile(file) } - public start(sessionId: string): void { - if (this.controller.isPolling && this.controller.currentId === sessionId) return - this.ctx.log(`Starting diff polling for session ${sessionId}`) - void this.activate(sessionId, true, true) + public start(id: string): void { + if (this.controller.isPolling && this.controller.currentId === id) return + this.ctx.log(`Starting diff polling for ${id}`) + void this.activate(id, true, true) } public stop(): void { this.controller.stop() this.target = undefined + this.poll = false } - private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + /** + * Set or clear an ephemeral base override for a context (worktree or local), + * then re-activate the current source so it refetches against the new base. + * Passing undefined clears the override and falls back to the recorded parent. + */ + public async setBase(id: string, branch: string | undefined): Promise { + const { ctx } = parseDiffId(id) + if (branch) this.baseOverrides.set(ctx, branch) + else this.baseOverrides.delete(ctx) + // Nothing to rebuild when the context isn't active; the override is + // picked up the next time start()/request() resolves it. + if (this.controller.currentId !== id) return + // Route through activate() so the base is re-resolved and pushed via + // setContext() — SourceController.reactivate() alone would rebuild the + // source against the stale context captured by the last activate(). The + // recorded poll intent preserves watch mode even when the initial fetch + // is still in flight (isPolling only turns true once it resolves). + await this.activate(id, this.poll, true) + } + + /** Branch picker data for a context's directory, using any active override. */ + public async branches(id: string) { + await this.ready("stateReady rejected, continuing diff branches resolve:") + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) + if (!target) return undefined + return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory) + } + + private async activate(id: string, poll: boolean, fetch: boolean): Promise { this.target = undefined - this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) - await this.controller.activate(sessionId, { poll, fetch }) + this.poll = poll + await this.ready("stateReady rejected, continuing diff activate:") + const { ctx } = parseDiffId(id) + const resolved = await this.resolve(ctx) + this.target = resolved ? { sessionId: id, ...resolved } : undefined + // Clear any stale source notice up front; sources only push a notice when + // one is active, so a swap away from a noticing source must reset it. + this.ctx.post({ type: "agentManager.worktreeDiffNotice", sessionId: id, notice: undefined }) + this.controller.setContext({ + workspaceRoot: this.ctx.getRoot(), + dir: resolved?.directory, + // The resolved base already bakes in any ephemeral override (see + // resolve()), so pass it as the explicit base and leave + // baseBranchOverride unset to avoid double resolution. + baseBranch: resolved?.baseBranch, + // Agent Manager always knows its intended directory (LOCAL resolves to + // the root). Never fall back to the workspace root for an unresolvable + // worktree context — return an empty diff instead. + strictDir: true, + git: this.ctx.git, + log: (...args) => this.ctx.log(...args), + }) + await this.controller.activate(id, { poll, fetch }) } - private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { - if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal() + private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal() const state = this.ctx.getState() if (!state) { - this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`) return undefined } - const session = state.getSession(sessionId) - if (!session) { - this.ctx.log( - `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, - ) - return undefined - } - if (!session.worktreeId) { - this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) - return undefined - } - - const worktree = state.getWorktree(session.worktreeId) + // The context is the worktree itself (the sidebar selection), not one of + // its sessions — resolution survives session churn inside the worktree. + const worktree = state.getWorktree(ctxId) if (!worktree) { - this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: worktree ${ctxId} not found`) return undefined } - return { directory: worktree.path, baseBranch: remoteRef(worktree) } + const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree) + return { directory: worktree.path, baseBranch: base } } private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> { - return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) + const root = this.ctx.getRoot() + if (!root) return undefined + const override = this.baseOverrides.get(LOCAL_DIFF_ID) + if (override) { + return { directory: root, baseBranch: override } + } + return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root) } private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } - private source(sessionId: string): DiffSource { - const descriptor: DiffSourceDescriptor = { - id: sessionId, - type: "workspace", - group: "Git", - capabilities: { revert: true, comments: true }, - } - + /** + * Build the active source for a composite id by delegating to the catalog. + * The composite id (`ctx#scope`, or `ctx#session:` for the session + * scope) is preserved as the descriptor id so the webview keys diff data by + * context+scope. Context resolution (dir/base) already happened in + * activate() and is carried by the PanelContext. + */ + private source(id: string, panelCtx: PanelContext): DiffSource { + const { ctx, scope, sessionId } = parseDiffId(id) + const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx, sessionId), panelCtx) return { - descriptor, - fetch: () => this.fetch(sessionId), - fetchFile: (file) => this.fetchFile(sessionId, file), - revert: (file) => this.revertFile(sessionId, file), + ...built, + descriptor: { ...built.descriptor, id }, } } - private async fetch(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - const target = await this.ensureTarget(sessionId) - if (!target) return { diffs: [], stopPolling: true } - - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - return { diffs: files as AgentManagerDiffFile[] } - } - - private async fetchFile(sessionId: string, file: string): Promise { - await this.ready("stateReady rejected, continuing diff detail resolve:") - return this.details.run(async () => { - const target = await this.ensureTarget(sessionId) - if (!target) return null - - try { - return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) - return null - } - }) - } - - private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> { await this.ready("stateReady rejected, continuing revert resolve:") - const target = await this.resolveTarget(sessionId) + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) if (!target) return { ok: false, message: "Could not resolve diff target" } try { @@ -285,19 +319,6 @@ export class WorktreeDiffController { } } - private async ensureTarget(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) return undefined - if (this.target?.sessionId === sessionId) return this.target - return await this.resolveTarget(sessionId) - } - - private async resolveTarget(sessionId: string): Promise { - const target = await this.resolve(sessionId) - if (!target) return undefined - this.target = { sessionId, ...target } - return this.target - } - private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { this.ctx.post({ type: "agentManager.revertWorktreeFileResult", diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts index d840f67eb5..f589a7c4eb 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -56,63 +56,14 @@ export class WorktreeImporter { } async branch(branch: string): Promise { - const manager = this.host.manager() - const state = this.host.state() - if (!manager || !state) { - this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) - return - } - if (this.busy()) return - - this.importing = true - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Creating worktree from branch...", - }) - const result = await manager.createWorktree({ existingBranch: branch }) - const worktree = state.addWorktree({ - branch: result.branch, - path: result.path, - parentBranch: result.parentBranch, - remote: result.remote, - branchOwned: false, - }) - this.host.push() - - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Running setup script...", - branch: result.branch, - worktreeId: worktree.id, - }) - await this.host.setup(result.path, result.branch, worktree.id) - - const session = await this.host.session(result.path, result.branch, worktree.id) - if (!session) throw new Error("Failed to create session") - - state.addSession(session.id, worktree.id) - this.host.register(session.id, result.path) - this.host.ready(session.id, result, worktree.id) - this.host.post({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` }) - this.host.log(`Imported branch ${branch} as worktree ${worktree.id}`) - } catch (error) { - state.removeWorktree(worktree.id) - await manager.removeWorktree(result.path) - this.host.push() - throw error - } - } catch (error) { - this.importError(error, `Branch "${branch}" is already checked out in another worktree`) - } finally { - this.importing = false - } + await this.run({ branch }) } async pr(url: string): Promise { + await this.run({ url }) + } + + private async run(target: { branch: string } | { url: string }): Promise { const manager = this.host.manager() const state = this.host.state() if (!manager || !state) { @@ -120,11 +71,21 @@ export class WorktreeImporter { return } if (this.busy()) return - this.importing = true + const branch = "branch" in target + const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." + const setup = branch ? "Running setup script..." : "Setting up worktree..." + const duplicate = branch + ? `Branch "${target.branch}" is already checked out in another worktree` + : "This PR's branch is already checked out in another worktree" try { - this.host.post({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." }) - const result = await manager.createFromPR(url) + const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const + this.host.post({ ...progress, message: creating }) + const result = branch + ? await manager.createWorktree({ existingBranch: target.branch }) + : await manager.createFromPR(target.url) + const success = branch ? `Opened branch ${target.branch}` : `Opened PR branch ${result.branch}` + const log = branch ? `Imported branch ${target.branch}` : `Imported PR ${target.url}` const worktree = state.addWorktree({ branch: result.branch, path: result.path, @@ -133,29 +94,16 @@ export class WorktreeImporter { branchOwned: false, }) this.host.push() - try { - this.host.post({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Setting up worktree...", - branch: result.branch, - worktreeId: worktree.id, - }) + this.host.post({ ...progress, message: setup, branch: result.branch, worktreeId: worktree.id }) await this.host.setup(result.path, result.branch, worktree.id) - const session = await this.host.session(result.path, result.branch, worktree.id) if (!session) throw new Error("Failed to create session") - state.addSession(session.id, worktree.id) this.host.register(session.id, result.path) this.host.ready(session.id, result, worktree.id) - this.host.post({ - type: "agentManager.importResult", - success: true, - message: `Opened PR branch ${result.branch}`, - }) - this.host.log(`Imported PR ${url} as worktree ${worktree.id}`) + this.host.post({ type: "agentManager.importResult", success: true, message: success }) + this.host.log(`${log} as worktree ${worktree.id}`) } catch (error) { state.removeWorktree(worktree.id) await manager.removeWorktree(result.path) @@ -163,7 +111,7 @@ export class WorktreeImporter { throw error } } catch (error) { - this.importError(error, "This PR's branch is already checked out in another worktree") + this.importError(error, duplicate) } finally { this.importing = false } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index fa36aaecf2..8eb2d0ffdf 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -90,12 +90,17 @@ export class DiffSourceCatalog implements vscode.Disposable { } build(id: string, ctx: PanelContext): DiffSource { + const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log } if (id === WORKSPACE_SOURCE_ID) { - return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) + return createWorktreeDiffSource({ + ...opts, + baseBranchOverride: ctx.baseBranchOverride, + baseBranch: ctx.baseBranch, + }) } - if (id === STAGED_SOURCE_ID) return createStagedDiffSource() - if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts) + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts) if (id.startsWith(TURN_PREFIX)) { const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") @@ -108,14 +113,22 @@ export class DiffSourceCatalog implements vscode.Disposable { if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + return createSessionDiffSource( + sessionId, + this.sessionFetch, + ctx.dir ?? ctx.workspaceRoot, + this.checkSnapshotsEnabled, + ) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) } - async listWorkspaceBranches(override: string | undefined): Promise { - const root = getWorkspaceRoot() + async listWorkspaceBranches( + override: string | undefined, + dir?: string, + ): Promise { + const root = dir ?? getWorkspaceRoot() if (!root) return undefined const git = this.ensureBranchGit() diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index d21a508902..8cd250743c 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface StagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the git index and HEAD — what `git diff --cached` would show. * Polls on the standard interval; revert isn't supported (use `git reset` from * a real git client). Read-only view. */ -export function createStagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Staged") - const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listEntries = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index b4d0c99616..5fcb03793d 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface UnstagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the working tree and the index — what `git diff` shows for * tracked files, plus untracked files (treated as fully-added). Read-only; * polls on the standard interval. */ -export function createUnstagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") - const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listTracked = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 1f3d8ee25a..89e750a1c8 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions { * the current branch — only the comparison target changes. Reset on dispose. */ baseBranchOverride?: string + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. Prevents an unresolvable + * worktree context from silently diffing the main checkout. + */ + strictDir?: boolean + /** + * Explicit base branch to diff against. When set, the source skips + * auto-resolution (tracking → default) and diffs against this ref directly. + * Agent Manager passes the worktree's recorded parent so a worktree always + * compares against its own base even when the workspace default differs. + */ + baseBranch?: string + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void } /** @@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions { * extension host — no `kilo serve` round-trip. */ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") - const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) - const git = new GitOps({ log }) + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) + + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } // Cached between fetches so repeated polling doesn't re-resolve the base // branch every tick. Reset only on dispose (when the source is swapped out). @@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const resolveTarget = async (): Promise => { if (target) return target + if (opts.baseBranch) { + const dir = root() + if (!dir) { + log("Local diff: no directory (explicit base mode)") + return + } + target = { directory: dir, baseBranch: opts.baseBranch } + log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`) + return target + } if (opts.baseBranchOverride) { - const root = getWorkspaceRoot() - if (!root) { + const dir = root() + if (!dir) { log("Local diff: no workspace root (override mode)") return } - const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log) if (!resolved) { log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) } else { - target = { directory: root, baseBranch: resolved } + target = { directory: dir, baseBranch: resolved } log(`Local diff: using override base=${resolved}`) return target } } - target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + target = await resolveLocalDiffTarget(git, log, root()) return target } @@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() target = undefined }, } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 913d4560a3..3ff72904e1 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -10,6 +10,27 @@ export interface PanelContext { hidePicker?: boolean /** User-picked base branch for the workspace source. Undefined = auto. */ baseBranchOverride?: string + /** + * Explicit directory to diff inside, overriding the workspace root lookup. + * Agent Manager passes a worktree path so its sources operate in the + * worktree rather than the main checkout. + */ + dir?: string + /** + * When true, a source whose `dir` resolves to undefined returns an empty + * diff instead of falling back to the workspace root. Agent Manager sets + * this so an unresolvable worktree context never silently diffs the main + * checkout. + */ + strictDir?: boolean + /** + * Explicit base ref for the workspace source, skipping auto-resolution. + * Agent Manager passes the worktree's recorded parent ref. + */ + baseBranch?: string + /** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */ + git?: import("../agent-manager/GitOps").GitOps + log?: (...args: unknown[]) => void } export type DiffImageError = "too-large" | "unreadable" diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index c750637d18..b1549e3c47 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -203,6 +203,8 @@ export function activate(context: vscode.ExtensionContext) { const ctx = agentManagerHost.wrapExistingPanel(panel, { onBeforeMessage: (msg) => agentManagerProvider.handleMessage(msg), worktreeDirectories: () => agentManagerProvider.getWorktreeDirectories(), + workspaceRoot: () => agentManagerProvider.workspaceRoot(), + projectId: () => agentManagerProvider.projectId(), }) agentManagerProvider.deserializePanel(ctx) return Promise.resolve() diff --git a/packages/kilo-vscode/src/indexing-consent.ts b/packages/kilo-vscode/src/indexing-consent.ts new file mode 100644 index 0000000000..6636bc571c --- /dev/null +++ b/packages/kilo-vscode/src/indexing-consent.ts @@ -0,0 +1,109 @@ +import * as path from "path" +import type * as vscode from "vscode" +import simpleGit from "simple-git" +import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project/paths" +import { ProjectRegistry } from "./agent-manager/project/registry" + +const KEY = "kilo.indexingConsent.v1" + +interface File { + version: 1 + projects: Record +} + +export interface IndexingProject { + id: string + root: string + label: string +} + +interface Storage { + read(): unknown + write(value: unknown): PromiseLike | void +} + +function parse(raw: unknown): File { + if (!raw || typeof raw !== "object") return { version: 1, projects: {} } + const file = raw as Partial + if (file.version !== 1 || !file.projects || typeof file.projects !== "object") { + return { version: 1, projects: {} } + } + return { + version: 1, + projects: Object.fromEntries(Object.entries(file.projects).filter((entry) => typeof entry[1] === "boolean")), + } +} + +export class IndexingConsentStore { + private queue: Promise = Promise.resolve() + + constructor( + private readonly storage: Storage, + private readonly resolve: (dir: string) => Promise, + ) {} + + async project(dir: string): Promise { + const root = (await this.resolve(dir)) ?? canonicalizePath(dir) + return { id: projectIdFor(root), root, label: path.basename(root) } + } + + enabled(id: string): boolean { + return parse(this.storage.read()).projects[id] === true + } + + set(id: string, enabled: boolean): Promise { + const task = this.queue + .catch(() => {}) + .then(async () => { + const file = parse(this.storage.read()) + await this.storage.write({ ...file, projects: { ...file.projects, [id]: enabled } }) + }) + this.queue = task.then( + () => {}, + () => {}, + ) + return task + } + + async list(primary: string | undefined, extras: Array<{ root: string; label?: string }>): Promise { + const dirs = [...(primary ? [{ root: primary }] : []), ...extras] + const projects = await Promise.all( + dirs.map(async (entry) => ({ + ...(await this.project(entry.root)), + label: entry.label ?? path.basename(entry.root), + })), + ) + return projects.filter( + (project, index) => !projects.slice(0, index).some((item) => samePath(item.root, project.root)), + ) + } +} + +const stores = new WeakMap() + +export function indexingConsentStore(context: vscode.ExtensionContext): IndexingConsentStore { + const existing = stores.get(context) + if (existing) return existing + const storage = { + read: () => context.globalState.get(KEY), + write: (value: unknown) => context.globalState.update(KEY, value), + } + const store = new IndexingConsentStore(storage, (dir) => + resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args)), + ) + stores.set(context, store) + return store +} + +export function registeredProjects(context: vscode.ExtensionContext) { + return ( + new ProjectRegistry({ + read: () => context.globalState.get("agentManager.projects"), + write: (value) => Promise.resolve(context.globalState.update("agentManager.projects", value)), + }) + .list() + // Consent must never be offered for a repository the user has not trusted: + // enabling indexing sends the directory to the backend for reading. + .filter((project) => project.trusted) + ) +} diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index e5dbd40e44..c9825cb771 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -408,7 +408,7 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } - | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" } + | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" } | { type: "permissionRequest" permission: { diff --git a/packages/kilo-vscode/src/kilo-provider/config-bindings.ts b/packages/kilo-vscode/src/kilo-provider/config-bindings.ts new file mode 100644 index 0000000000..585fdb6dc3 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/config-bindings.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "crypto" + +export type ConfigScope = "global" | "project" + +export interface ConfigTarget { + scope: ConfigScope + path: string + revision: string + exists: boolean + writable: boolean + raw: Record +} + +export interface ConfigProject { + id: string + root: string + generation: number + pinned: boolean +} + +export interface ConfigBinding { + id: string + connection: number + scope: ConfigScope + directory: string + target: ConfigTarget + project?: ConfigProject +} + +export class ConfigBindings { + private readonly bindings = new Map() + + create(input: Omit): ConfigBinding { + const binding = { ...input, id: randomUUID() } + // Only the newest binding per scope+directory can still be saved against, + // so drop the superseded ones instead of growing forever on read-only + // refreshes (external config edits re-issue bindings without consuming). + for (const [id, existing] of this.bindings) { + if (existing.scope === binding.scope && existing.directory === binding.directory) this.bindings.delete(id) + } + this.bindings.set(binding.id, binding) + return binding + } + + get( + id: string | undefined, + connection: number, + valid: (project: ConfigProject) => boolean, + ): ConfigBinding | undefined { + if (!id) return undefined + const binding = this.bindings.get(id) + if (!binding || binding.connection !== connection) return undefined + if (binding.project && !valid(binding.project)) return undefined + return binding + } + + consume(id: string): void { + this.bindings.delete(id) + } + + clear(): void { + this.bindings.clear() + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts new file mode 100644 index 0000000000..90ca1ac6fc --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -0,0 +1,22 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { configFeatures } from "../features" +import { retry } from "../services/cli-backend/retry" +import type { ConfigTarget } from "./config-bindings" + +type Client = Pick +type Settings = { maxCost: number; languageCommitMessage: string; multiProject: boolean } +export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => client.config.get({ directory: dir }, { throwOnError: true })), + client.global.config.get({ throwOnError: true }), + client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) + return { + config, + globalConfig: global, + targets: overlay?.targets as { global: ConfigTarget; project: ConfigTarget } | undefined, + collections: overlay?.collections, + settings: settings(), + features: configFeatures(config), + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 1273d65584..f67a2144fa 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -4,6 +4,8 @@ import { routeInputToolMessage } from "../services/input-tools" import type { KiloConnectionService } from "../services/cli-backend/connection-service" import type { SuggestionContext } from "./handlers/suggestion" import type { KiloClient } from "@kilocode/sdk/v2/client" +import { buildChatSettingsMessage } from "./chat-settings" +import { buildThroughputSettingMessage } from "./throughput-settings" type Ctx = { question: SuggestionContext @@ -11,6 +13,7 @@ type Ctx = { connection: KiloConnectionService dir: string post: (msg: unknown) => void + browserSettings: () => void exportTranscript: (sessionID: string) => Promise copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void @@ -53,5 +56,17 @@ export async function routeEarlyMessage( ctx.openSessions(ids) return true } + if (message.type === "requestChatSettings") { + ctx.post(buildChatSettingsMessage()) + return true + } + if (message.type === "requestThroughputSetting") { + ctx.post(buildThroughputSettingMessage()) + return true + } + if (message.type === "requestBrowserSettings") { + ctx.browserSettings() + return true + } return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post }) } diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index 2b6e3608ca..52008fdab4 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -106,7 +106,7 @@ export async function handlePermissionResponse( } const replyResult = await ctx.client.permission - .reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true }) + .reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true }) .then(() => "ok" as const) .catch((error: unknown) => { if (isNotFoundError(error)) return "stale" as const diff --git a/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts b/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts index 8fbd0ace48..be8a580a40 100644 --- a/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts +++ b/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts @@ -1,21 +1,23 @@ import * as vscode from "vscode" +import type { IndexingProject } from "../indexing-consent" -type Post = (msg: unknown) => void - -export function buildIndexingSettingsMessage() { +export function buildIndexingSettingsMessage(consent = false, projects: IndexingProject[] = [], projectId?: string) { const config = vscode.workspace.getConfiguration("kilo-code.new.indexing") return { type: "indexingSettingsLoaded" as const, settings: { showButtonWhenDisabled: config.get("showButtonWhenDisabled", true), + consent, + projects, + projectId, }, } } -export function watchIndexingConfig(post: Post): vscode.Disposable { +export function watchIndexingConfig(post: () => void): vscode.Disposable { return vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("kilo-code.new.indexing")) { - post(buildIndexingSettingsMessage()) + post() } }) } diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index 63b76a2766..fcd8481bbe 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -1,3 +1,5 @@ +import type { ProjectRouteService } from "../agent-manager/project/route" + export type KiloProviderOptions = { /** Context key updated from focus events reported by this provider's webview. */ focusContext?: string @@ -7,6 +9,27 @@ export type KiloProviderOptions = { slimEditMetadata?: boolean tabTitle?: (title: string) => void worktreeDirectories?: () => string[] + /** + * Dynamic root directory override. When present, it replaces the + * workspaceFolders[0] fallback so the provider's sessions, prompts, and + * refreshes follow the host's active project (Agent Manager). + */ + rootDirectory?: () => string | undefined /** Composite hosts (Agent Manager) own viewed/presence registration themselves. */ disableViewedRegistration?: boolean + /** + * Project route registry shared by all Agent Manager panels. When set, the + * provider resolves project-qualified session refs to exact directories and + * refuses to silently retarget an ambiguous raw session id to the active + * root. Non-Agent-Manager providers leave this undefined and behave exactly + * as before. + */ + routeService?: ProjectRouteService + /** + * Resolve the active Agent Manager project for a raw session id, so a + * project-qualified {@link SessionRef} can be built when the caller does not + * already carry one. Returns undefined when no project is active or the id + * is ambiguous. + */ + projectQualifier?: () => { projectId: string } | undefined } diff --git a/packages/kilo-vscode/src/kilo-provider/session-search.ts b/packages/kilo-vscode/src/kilo-provider/session-search.ts index 7552fc6802..e525b5a854 100644 --- a/packages/kilo-vscode/src/kilo-provider/session-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/session-search.ts @@ -4,6 +4,7 @@ type Item = { id: string title: string updated: number + worktreeName?: string } type Message = { @@ -22,11 +23,14 @@ type Input = { } /** - * Past-chat mention search. Lists root sessions for the directory the current - * chat runs in (workspace root for the sidebar, the worktree for Agent Manager - * sessions) — the same directory-scoped `session.list` the session history and - * Agent Manager search are built on. Fuzzy title filtering happens in the - * webview (same mechanism as the Agent Manager sidebar search). + * Past-chat mention search. Lists root sessions across the current directory's + * worktree family (the repo root and its sibling worktrees for git projects, + * just the directory itself otherwise) — the same family-wide listing the + * Agent Manager session search and the CLI's past-chat picker are built on. + * Every session in the family shares the project, so any of them can be + * attached regardless of which worktree the current chat runs in. Fuzzy title + * filtering happens in the webview (same mechanism as the Agent Manager + * sidebar search). */ export async function handleSessionSearch(input: Input): Promise { const client = input.client @@ -39,10 +43,18 @@ export async function handleSessionSearch(input: Input): Promise { const dir = input.dir(id) try { - const res = await client.session.list({ directory: dir, roots: true, limit: 50 }, { throwOnError: true }) + const res = await client.experimental.session.list( + { worktrees: true, roots: true, directory: dir, limit: 50 }, + { throwOnError: true }, + ) const sessions: Item[] = res.data .filter((session) => session.id !== input.exclude && session.title) - .map((session) => ({ id: session.id, title: session.title, updated: session.time.updated })) + .map((session) => ({ + id: session.id, + title: session.title, + updated: session.time.updated, + worktreeName: session.worktreeName, + })) input.post({ type: "sessionSearchResult", sessions, requestId: input.message.requestId }) } catch (err) { console.error("[Kilo New] Session search failed:", err) diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts new file mode 100644 index 0000000000..3aa4dc7483 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/fa.ts @@ -0,0 +1,6 @@ +export const dict = { + "server.processExited": "فرآیند CLI با کد {{code}} قبل از راه‌اندازی سرور خاتمه یافت", + "server.startupTimeout": "زمان راه‌اندازی سرور پس از {{seconds}} ثانیه به پایان رسید", + "remote.connected": "Kilo Remote: متصل شد", + "remote.connecting": "Kilo Remote: در حال اتصال…", +} as const diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts index 82c8c9358a..d41b11bd2c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/index.ts @@ -6,6 +6,7 @@ import { dict as da } from "./da" import { dict as de } from "./de" import { dict as en } from "./en" import { dict as es } from "./es" +import { dict as fa } from "./fa" import { dict as fr } from "./fr" import { dict as it } from "./it" import { dict as ja } from "./ja" @@ -29,6 +30,7 @@ const bundles: Record> = { de, en, es, + fa, fr, it, ja, diff --git a/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts b/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts new file mode 100644 index 0000000000..593c89eb68 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/autocomplete/fa.ts @@ -0,0 +1,27 @@ +// English runtime translations for autocomplete (kilocode:autocomplete.* namespace) +// Source: src/i18n/locales/en/kilocode.json → "autocomplete" section + +export const dict = { + "kilocode:autocomplete.statusBar.enabled": "$(kilo-logo) تکمیل خودکار", + "kilocode:autocomplete.statusBar.snoozed": "به تعویق افتاده", + "kilocode:autocomplete.statusBar.warning": "$(warning) تکمیل خودکار", + "kilocode:autocomplete.statusBar.tooltip.basic": "تکمیل خودکار Kilo Code", + "kilocode:autocomplete.statusBar.tooltip.noUsableProvider": + "**هیچ مدل تکمیل خودکاری پیکربندی نشده است**\n\nبرای فعال‌سازی تکمیل خودکار، یک پروفایل با یکی از ارائه‌دهندگان پشتیبانی‌شده زیر اضافه کنید: {{providers}}.\n\n[باز کردن تنظیمات]({{command}})", + "kilocode:autocomplete.statusBar.tooltip.completionSummary": + "{{count}} تکمیل بین {{startTime}} و {{endTime}} انجام شد، با هزینه کل {{cost}}.", + "kilocode:autocomplete.statusBar.tooltip.providerInfo": + "تکمیل خودکار توسط {{model}} از طریق {{provider}} ارائه می‌شود.", + "kilocode:autocomplete.statusBar.cost.zero": "۰.۰۰$", + "kilocode:autocomplete.statusBar.cost.lessThanCent": "<۰.۰۱$", + "kilocode:autocomplete.codeAction.title": "Kilo Code: ویرایش‌های پیشنهادی", + "kilocode:autocomplete.incompatibilityExtensionPopup.message": + "تکمیل خودکار Kilo Code به دلیل تعارض با GitHub Copilot مسدود شده است. برای رفع این مشکل، باید پیشنهادات درون‌خطی Copilot را غیرفعال کنید.", + "kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "غیرفعال کردن Copilot", + "kilocode:autocomplete.incompatibilityExtensionPopup.disableInlineAssist": "غیرفعال کردن تکمیل خودکار", + "kilocode:autocomplete.creditsExhausted.message": + "تکمیل خودکار Kilo Code متوقف شده است. دلایل احتمالی: حساب Kilo شما اعتبار کافی ندارد، یا کلید API پیکربندی‌شده (BYOK) به سقف مجاز خود رسیده است. برای از سرگیری تکمیل خودکار، اعتبار Kilo اضافه کنید یا تنظیمات کلید API خود را بررسی کنید.", + "kilocode:autocomplete.creditsExhausted.addCredits": "افزودن اعتبار", + "kilocode:autocomplete.authError.message": + "تکمیل خودکار Kilo Code به دلیل مشکل احراز هویت متوقف شده است. دلایل احتمالی: وارد Kilo نشده‌اید، یا کلید API (BYOK) شما نامعتبر یا وارد نشده است. لطفاً دوباره وارد شوید یا تنظیمات کلید API ارائه‌دهنده خود را بررسی کنید.", +} diff --git a/packages/kilo-vscode/src/services/i18n/fa.ts b/packages/kilo-vscode/src/services/i18n/fa.ts new file mode 100644 index 0000000000..e8daa62e10 --- /dev/null +++ b/packages/kilo-vscode/src/services/i18n/fa.ts @@ -0,0 +1,7 @@ +import { dict as autocompleteDict } from "./autocomplete/fa" + +export { autocompleteDict } + +export const dict = { + ...autocompleteDict, +} as const diff --git a/packages/kilo-vscode/src/services/i18n/index.ts b/packages/kilo-vscode/src/services/i18n/index.ts index 4da8dda292..1090b84b39 100644 --- a/packages/kilo-vscode/src/services/i18n/index.ts +++ b/packages/kilo-vscode/src/services/i18n/index.ts @@ -6,6 +6,7 @@ import { dict as de } from "./de" import { dict as en } from "./en" import { type dict as enDict } from "./en" import { dict as es } from "./es" +import { dict as fa } from "./fa" import { dict as fr } from "./fr" import { dict as it } from "./it" import { dict as ja } from "./ja" @@ -28,6 +29,7 @@ const bundles: Record> = { de, en, es, + fa, fr, it, ja, diff --git a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts index eb4830ec6c..c2ca48b72b 100644 --- a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts +++ b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts @@ -44,6 +44,15 @@ function field(page: Page, title: string) { return page.locator('[data-slot="settings-row"]', { hasText: title }).locator("input") } +// Scope select triggers by their row title: the tab gained a project selector, +// so positional lookups silently target the wrong control. +function selectIn(page: Page, title: string) { + return page + .locator('[data-slot="settings-row"]', { hasText: title }) + .locator('[data-slot="select-select-trigger"]') + .first() +} + test("provider switch writes to selected provider bucket", async ({ page }) => { await page.setViewportSize({ width: 420, height: 720 }) await page.goto(storyUrl(), { waitUntil: "load" }) @@ -52,7 +61,7 @@ test("provider switch writes to selected provider bucket", async ({ page }) => { const saved = page.getByTestId("indexing-provider-save") - const trigger = page.locator('[data-component="select"] [data-slot="select-select-trigger"]').first() + const trigger = selectIn(page, "Embedding provider") await trigger.click() await page.locator('[data-slot="select-select-item-label"]', { hasText: "Gemini" }).click() @@ -123,7 +132,7 @@ test("Kilo exposes only supported embedding model presets", async ({ page }) => await expect(page.getByText("Embedding model", { exact: true })).toHaveCount(0) await expect(page.getByText("Vector dimension", { exact: true })).toBeVisible() - const preset = page.locator('[data-component="select"] [data-slot="select-select-trigger"]').nth(1) + const preset = selectIn(page, "Kilo model preset") await expect(preset).toContainText("Provider Model") const dimension = field(page, "Vector dimension").first() diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 38105c8a8f..52e607fbd4 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.16", + "version": "7.4.17", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 03073ea4c5..5e2b62082f 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -6,7 +6,7 @@ const NAMES = [ "Providers", "Agent Behaviour", "Auto-Approve", - "Browser", + "Web Tools", "Checkpoints", "Display", "Autocomplete", diff --git a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts index 0820dc818e..796a2d0eeb 100644 --- a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -1,11 +1,52 @@ import { describe, expect, it } from "bun:test" import { + mcpConfigScope, + mcpEnabledPatch, selectedAgentNumberOverrideValue, selectedAgentTextOverrideValue, selectedDefaultAgentValue, shouldClearDefaultAgentWhenAgentBecomesUnavailable, } from "../../webview-ui/src/components/settings/agent-behaviour-patches" +describe("mcpEnabledPatch", () => { + it("returns only the enabled-state patch", () => { + expect(mcpEnabledPatch("docs", false)).toEqual({ + mcp: { + docs: { + enabled: false, + }, + }, + }) + }) + + it("routes project-defined servers to project config", () => { + expect( + mcpConfigScope("docs", { + mcp: [{ key: "docs", source: "project" }], + }), + ).toBe("project") + }) + + it("routes global servers to global config", () => { + const collections = { + mcp: [{ key: "docs", source: "global" as const }], + } + expect(mcpConfigScope("docs", collections)).toBe("global") + }) + + it("keeps system, default, and unknown servers runtime-only", () => { + const collections = { + mcp: [ + { key: "legacy", source: "system" as const }, + { key: "builtin", source: "default" as const }, + ], + } + expect(mcpConfigScope("legacy", collections)).toBeUndefined() + expect(mcpConfigScope("builtin", collections)).toBeUndefined() + expect(mcpConfigScope("unknown", collections)).toBeUndefined() + }) +}) + describe("selectedAgentTextOverrideValue", () => { it("maps an empty text field value to a null delete sentinel", () => { expect(selectedAgentTextOverrideValue("")).toBeNull() diff --git a/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts new file mode 100644 index 0000000000..17809d05e8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { LOCAL } from "../../webview-ui/agent-manager/navigate" +import { ambientDecision, createAmbientSetup } from "../../webview-ui/agent-manager/terminal/ambient" +import { createTerminalState } from "../../webview-ui/agent-manager/terminal/state" + +describe("ambientDecision", () => { + it("waits while setup is still running", () => { + expect(ambientDecision(undefined, "wt-1", "wt-1")).toBe("wait") + expect(ambientDecision({ state: "running", kind: "setup" }, "wt-1", "wt-1")).toBe("wait") + expect(ambientDecision({ state: "stopping", kind: "setup" }, "wt-1", "wt-1")).toBe("wait") + }) + + it("hides the panel after a clean exit in the revealed context", () => { + expect(ambientDecision({ state: "exited", exitCode: 0, kind: "setup" }, "wt-1", "wt-1")).toBe("hide") + }) + + it("keeps the panel when setup failed", () => { + expect(ambientDecision({ state: "exited", exitCode: 1, kind: "setup" }, "wt-1", "wt-1")).toBe("keep") + expect(ambientDecision({ state: "failed", kind: "setup" }, "wt-1", "wt-1")).toBe("keep") + }) + + it("keeps the panel when the user switched context before settle", () => { + expect(ambientDecision({ state: "exited", exitCode: 0, kind: "setup" }, LOCAL, "wt-1")).toBe("keep") + }) +}) + +describe("createAmbientSetup tracking", () => { + function scene(panelOpen: boolean) { + const [selection] = createSignal("wt-1") + const [panel] = createSignal<"diff" | "terminal" | null>(panelOpen ? "terminal" : null) + const terms = createTerminalState(selection) + const ambient = createAmbientSetup({ terms, selection, sidePanel: panel, setSidePanel: () => undefined }) + return ambient + } + + it("remembers an ambient reveal only when the panel was closed", () => { + createRoot((dispose) => { + const closed = scene(false) + closed.reveal("wt-1", "script:setup") + expect(closed.pending()).toBeDefined() + const open = scene(true) + open.reveal("wt-1", "script:setup") + expect(open.pending()).toBeUndefined() + dispose() + }) + }) + + it("reveal and cancel drive the pending auto-hide", () => { + createRoot((dispose) => { + const ambient = scene(false) + ambient.reveal("wt-1", "script:setup") + expect(ambient.pending()).toEqual({ contextKey: "wt-1", terminalId: "script:setup" }) + ambient.cancel() + expect(ambient.pending()).toBeUndefined() + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 804471f9bb..353070c085 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from "bun:test" import fs from "node:fs" import path from "node:path" import { Project, SyntaxKind } from "ts-morph" +import { WorktreeImporter } from "../../src/agent-manager/worktree-importer" const ROOT = path.resolve(import.meta.dir, "../..") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") @@ -40,6 +41,13 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarToggleButton.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ProjectsSection.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ProjectSidebarBody.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ProjectList.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ProjectActions.tsx"), + 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/tab-rendering.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), @@ -60,6 +68,10 @@ const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts") const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts") const RUN_MESSAGE_FILE = path.join(ROOT, "src/agent-manager/run/message.ts") const TERMINAL_ROUTING_FILE = path.join(ROOT, "src/agent-manager/terminal-routing.ts") +const SCRIPT_TERMINAL_FILE = path.join(ROOT, "src/agent-manager/ScriptTerminalManager.ts") +const SCRIPT_TERMINAL_RUNTIME_FILE = path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts") +const RUN_TASK_FILE = path.join(ROOT, "src/agent-manager/run/task.ts") +const RUN_DESTINATION_FILE = path.join(ROOT, "src/agent-manager/run/destination.ts") function readAllCss(): string { return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n") @@ -151,7 +163,15 @@ describe("Agent Manager Provider Messages", () => { const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration) const method = cls?.getMethod(name) expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy() - return method!.getText() + const text = method!.getText() + // Follow one-line delegations into the extracted lifecycle module so the + // assertions keep covering the real handler logic. + const delegated = text.match(/return (\w+Lifecycle\w+)\(/) + if (!delegated) return text + const lifecycle = project.addSourceFileAtPath(path.join(ROOT, "src/agent-manager/provider-lifecycle.ts")) + const fn = lifecycle.getFunction(delegated[1]!) + expect(fn, `delegated function ${delegated[1]} not found in provider-lifecycle`).toBeTruthy() + return fn!.getText() } /** @@ -179,8 +199,38 @@ describe("Agent Manager Provider Messages", () => { expect(warmup).toBeLessThan(create) }) + /** + * Regression: WorktreeDiffController calls getRoot() eagerly during + * construction, so the project contexts must exist before it is created. + * Constructing them later crashed extension activation at runtime. + */ + it("creates project wiring before services that eagerly read the root", () => { + const text = fs.readFileSync(PROVIDER_FILE, "utf-8") + const wiring = text.indexOf("createProjectWiring(") + const diffs = text.indexOf("new WorktreeDiffController(") + + expect(wiring).toBeGreaterThanOrEqual(0) + expect(diffs).toBeGreaterThanOrEqual(0) + expect(wiring).toBeLessThan(diffs) + }) + + /** + * Regression: project-management messages must be consumed before the + * state gate, because selectProject triggers the state initialization + * that later messages wait for. + */ + it("handles project messages before state-gated dispatch", () => { + const body = getMethodBody("onMessage") + getMethodBody("dispatchMessage") + const projects = body.indexOf("handleProjectMessage(m, this.projects)") + const gate = body.indexOf("if (this.shouldWaitForState(m))") + + expect(projects).toBeGreaterThanOrEqual(0) + expect(gate).toBeGreaterThanOrEqual(0) + expect(projects).toBeLessThan(gate) + }) + it("state-mutating messages wait for state initialization", () => { - const body = getMethodBody("shouldWaitForState") + const body = fs.readFileSync(path.join(ROOT, "src/agent-manager/project/state-gate.ts"), "utf-8") const messages = [ "agentManager.setTabOrder", "agentManager.setWorktreeOrder", @@ -196,13 +246,13 @@ describe("Agent Manager Provider Messages", () => { expect(body, `${message} should wait for loaded state`).toContain(message) } - expect(getMethodBody("onMessage")).toContain("if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type)") + expect(getMethodBody("dispatchMessage")).toContain("if (this.shouldWaitForState(m))") }) - it("initializeState updates local git exclude before loading persisted state", () => { - const body = getMethodBody("initializeState") - const exclude = body.indexOf("await this.ensureGitExclude(manager)") - const load = body.indexOf("const loaded = await state.load()") + it("context state init updates local git exclude before loading persisted state", () => { + const text = fs.readFileSync(path.join(ROOT, "src/agent-manager/project/init.ts"), "utf-8") + const exclude = text.indexOf("ensureGitExclude(") + const load = text.indexOf("state.load()") expect(exclude).toBeGreaterThanOrEqual(0) expect(load).toBeGreaterThanOrEqual(0) @@ -225,7 +275,7 @@ describe("Agent Manager Provider Messages", () => { expect(body).toContain("closedDrafts.add(sessionId)") expect(body).toContain('vscode.postMessage({ type: "agentManager.closeSession", sessionId })') expect(body).not.toContain('type: "agentManager.forgetSession"') - expect(getMethodBody("onCloseSession")).toContain("await this.panel?.sessions.abortSessions([sessionId])") + expect(getMethodBody("onCloseSession")).toContain("await host.sessions.abort([sessionId])") expect(text).toContain("if (created.draftID && closedDrafts.delete(created.draftID)) return") }) @@ -376,7 +426,19 @@ describe("Agent Manager Provider — onMessage routing", () => { setup() const method = cls.getMethod(name) expect(method, `method ${name} not found`).toBeTruthy() - return method!.getText() + const text = method!.getText() + // Follow one-line delegations into the extracted handler modules so the + // assertions keep covering the real handler logic. + const delegated = text.match(/return (\w+Lifecycle\w+|createMultiVersion)\(/) + if (!delegated) return text + const module = delegated[1] === "createMultiVersion" ? "provider-multi-version.ts" : "provider-lifecycle.ts" + const lifecycle = source.getProject().addSourceFileAtPath(path.join(ROOT, "src/agent-manager", module)) + const fn = lifecycle.getFunction(delegated[1]!) + expect(fn, `delegated function ${delegated[1]} not found in ${module}`).toBeTruthy() + // The multi-version flow spans phase helpers (createVersion, sendInitialPrompts), + // so ordering assertions need the whole module, not just the orchestrator. + if (delegated[1] === "createMultiVersion") return lifecycle.getText() + return fn!.getText() } function provider(): string { @@ -411,6 +473,7 @@ describe("Agent Manager Provider — onMessage routing", () => { "agentManager.stopRunScript", "agentManager.showTerminal", "agentManager.showLocalTerminal", + "agentManager.showWorktreeTerminal", "agentManager.showExistingLocalTerminal", "agentManager.requestRepoInfo", "agentManager.requestState", @@ -445,7 +508,7 @@ describe("Agent Manager Provider — onMessage routing", () => { }) it("onMessage delegates to cohesive routing groups", () => { - const text = body("onMessage") + const text = body("onMessage") + body("dispatchMessage") expect(text).toContain("onWorktreeMessage") expect(text).toContain("onSessionMessage") expect(text).toContain("onImportMessage") @@ -453,6 +516,53 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).not.toContain("agentManager.requestState") }) + it("routes script terminal close and resize messages before user terminals", () => { + const text = body("dispatchMessage") + expect(text.indexOf("this.scripts.manager.intercept(m)")).toBeLessThan( + text.indexOf("this.terminalRouter.handle(m)"), + ) + }) + + it("runs scripts through the vscode-free canonical PTY manager", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_FILE, "utf-8") + expect(text).toMatch(/client\.v2\.pty\s*\.create/) + expect(text).toContain("client.v2.pty.get") + expect(text).toContain("client.v2.pty.update") + expect(text).toContain("client.v2.pty.remove") + expect(text).not.toContain("vscode") + }) + + it("selects the Run adapter from the panel dropdown message", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_RUNTIME_FILE, "utf-8") + expect(text).toContain("pickRunStart") + expect(text).toContain("config.destination") + expect(text).not.toContain("readRunTerminalDestination") + expect(text.indexOf("pickRunStart")).toBeLessThan(text.indexOf("config.destination")) + }) + + it("keeps the legacy integrated Run adapter isolated and removable", () => { + const task = fs.readFileSync(RUN_TASK_FILE, "utf-8") + expect(task).toContain("vscode.tasks.executeTask") + expect(task).toContain("Remove this") + const dest = fs.readFileSync(RUN_DESTINATION_FILE, "utf-8") + expect(dest).not.toContain('from "vscode"') + expect(dest).toContain("pickRunStart") + expect(dest).not.toContain("getConfiguration") + }) + + it("clears retained script terminals before removing worktree state", () => { + for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) { + const text = body(name) + expect(text).toContain("host.clearRun(worktreeId)") + expect(text.indexOf("host.clearRun(worktreeId)")).toBeLessThan(text.indexOf("state.removeWorktree")) + } + const deleted = body("onDeleteWorktree") + expect(deleted.indexOf("host.skipStats")).toBeLessThan(deleted.indexOf("host.removeRun")) + const helper = fs.readFileSync(path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts"), "utf-8") + expect(helper).toContain('manager.clear("run", worktreeId, projectId)') + expect(helper).toContain('manager.clear("setup", worktreeId, projectId)') + }) + // -- onDeleteWorktree invariants ------------------------------------------- /** @@ -461,10 +571,10 @@ describe("Agent Manager Provider — onMessage routing", () => { */ it("onDeleteWorktree removes from disk, state, clears orphans, and pushes", () => { const text = body("onDeleteWorktree") - expect(text).toContain("manager.removeWorktree") + expect(text).toContain("worktreeManager().removeWorktree") expect(text).toContain("state.removeWorktree") - expect(text).toContain("clearSessionDirectory") - expect(text).toContain("this.pushState()") + expect(text).toContain("sessions.clearDirectory") + expect(text).toContain("host.push()") }) // -- onCreateWorktree invariants ------------------------------------------- @@ -476,8 +586,8 @@ describe("Agent Manager Provider — onMessage routing", () => { */ it("onCreateWorktree runs setup script before creating session", () => { const text = body("onCreateWorktree") - const setupIdx = text.indexOf("runSetupScriptForWorktree") - const sessionIdx = text.indexOf("createSessionInWorktree") + const setupIdx = text.indexOf("host.runSetup(") + const sessionIdx = text.indexOf("host.createSession(") expect(setupIdx, "setup script call must exist").toBeGreaterThan(-1) expect(sessionIdx, "session creation call must exist").toBeGreaterThan(-1) expect(setupIdx, "setup script must run before session creation").toBeLessThan(sessionIdx) @@ -494,8 +604,8 @@ describe("Agent Manager Provider — onMessage routing", () => { it("multi-version creation registers each session after publishing its worktree mapping", () => { const text = body("onCreateMultiVersion") - const ready = text.indexOf("this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)") - const register = text.indexOf("this.panel?.sessions.registerSession(session)") + const ready = text.indexOf("host.notifyReady(session.id, wt.result, wt.worktree.id)") + const register = text.indexOf("host.sessions.register(session)") const initial = text.indexOf("agentManager.sendInitialMessage") expect(ready, "multi-version path must publish ready state").toBeGreaterThan(-1) @@ -512,7 +622,7 @@ describe("Agent Manager Provider — onMessage routing", () => { */ it("onPromoteSession runs setup script before modifying session", () => { const text = body("onPromoteSession") - const setupIdx = text.indexOf("runSetupScriptForWorktree") + const setupIdx = text.indexOf("host.runSetup(") const moveIdx = text.indexOf("moveSession") expect(setupIdx).toBeGreaterThan(-1) expect(moveIdx).toBeGreaterThan(-1) @@ -570,18 +680,70 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("class WorktreeDiffController") expect(text).toContain("buildWorktreePatch") expect(text).toContain("revertFile") - expect(text).toContain("diffSummary") + // Summary/detail diff data comes from the shared DiffSourceCatalog sources + // (workspace/staged/unstaged/session), not a bespoke in-controller pipeline. + expect(text).toContain("catalog.build") expect(text).toContain("shouldStopDiffPolling") expect(providerText).toContain("this.diffs") }) it("worktree import behavior lives in the cohesive importer", () => { const text = importer() - const providerText = body("onImportMessage") - expect(text).toContain("class WorktreeImporter") - expect(text).toContain("createFromPR") - expect(text).toContain("createWorktree") - expect(providerText).toContain("this.importer") + for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value) + expect(body("onImportMessage")).toContain("this.importer") + }) + + it("preserves branch and PR import ordering and rollback", async () => { + const run = async (kind: "branch" | "pr", fail?: "setup" | "duplicate") => { + const events: string[] = [] + const create = async () => { + events.push("create") + if (fail === "duplicate") throw new Error("already checked out") + return { branch: "topic", path: "/repo/topic", parentBranch: "main" } + } + const importer = new WorktreeImporter({ + manager: () => + ({ createWorktree: create, createFromPR: create, removeWorktree: async () => events.push("disk") }) as never, + state: () => + ({ + addWorktree: (input: { branchOwned: boolean }) => ({ + id: events.push(`add:${input.branchOwned}`) ? "worktree" : "", + }), + addSession: () => events.push("state-session"), + removeWorktree: () => events.push("state-remove"), + }) as never, + post: (msg) => events.push("message" in msg ? String(msg.message) : msg.type), + push: () => events.push("push"), + setup: async () => { + events.push("setup") + if (fail === "setup") throw new Error("setup failed") + }, + session: async () => (events.push("session"), { id: "session" }) as never, + register: () => events.push("register"), + ready: () => events.push("ready"), + log: () => events.push("log"), + }) + const action = () => (kind === "branch" ? importer.branch("topic") : importer.pr("https://example.test/pull/1")) + await action() + if (fail === "setup") await action() + return events.join("|") + } + for (const kind of ["branch", "pr"] as const) { + const branch = kind === "branch" + const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." + const setup = branch ? "Running setup script..." : "Setting up worktree..." + const success = branch ? "Opened branch topic" : "Opened PR branch topic" + expect(await run(kind)).toBe( + `${creating}|create|add:false|push|${setup}|setup|session|state-session|register|ready|${success}|log`, + ) + expect(await run(kind, "setup")).toBe( + `${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed|${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed`, + ) + const duplicate = branch + ? 'Branch "topic" is already checked out in another worktree' + : "This PR's branch is already checked out in another worktree" + expect(await run(kind, "duplicate")).toBe(`${creating}|create|${duplicate}|${duplicate}`) + } }) }) @@ -602,7 +764,7 @@ describe("Agent Manager Webview — non-git sessionsLoaded fix", () => { // Find the agentManager.state handler block const start = tsx.indexOf('"agentManager.state"') expect(start, "agentManager.state handler must exist").toBeGreaterThan(-1) - const snippet = tsx.slice(start, start + 800) + const snippet = tsx.slice(start, start + 1600) expect(snippet, "must call setSessionsLoaded in the non-git branch").toContain("setSessionsLoaded") expect(snippet, "must check isGitRepo === false before setting sessionsLoaded").toMatch( /isGitRepo.*false|false.*isGitRepo/, @@ -790,9 +952,6 @@ const VSCODE_ALLOWED: Record = { "task-runner.ts": { note: "vscode adapter for SetupScriptRunner", }, - "run/task.ts": { - note: "vscode adapter for Agent Manager run scripts", - }, // Reads terminal.integrated.* and editor.font* config for xterm font settings "terminal-font.ts": { note: "vscode config reader for integrated terminal font settings", @@ -817,8 +976,8 @@ const VSCODE_ALLOWED: Record = { */ const MAX_LINES: Record = { "AgentManagerProvider.ts": { - maxLines: 2000, - note: "diff and import workflows are extracted into cohesive domain services; extract more orchestration next", + maxLines: 1900, + note: "worktree lifecycle handlers extracted into provider-lifecycle.ts; extract more orchestration next", }, } @@ -891,70 +1050,73 @@ describe("Agent Manager — VS Code import boundary", () => { }) }) -// --------------------------------------------------------------------------- -// Provider chain parity — sidebar App.tsx vs AgentManagerApp.tsx -// -// The agent manager reuses ChatView (and therefore MessageList, etc.) from the -// sidebar. Any context provider that ChatView's tree may call useXxx() on must -// also be present in the agent manager's provider chain. A missing provider -// crashes the entire SolidJS component tree silently. -// -// Regression: PR #7473 moved KiloNotifications into MessageList. It calls -// useNotifications(), but NotificationsProvider was only in App.tsx — the agent -// manager rendered a blank screen. -// --------------------------------------------------------------------------- - const APP_FILE = path.join(ROOT, "webview-ui/src/App.tsx") const AGENT_MANAGER_APP_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") +const PROVIDER_SHELL_FILE = path.join(ROOT, "webview-ui/src/context/provider-shell.tsx") -describe("Agent Manager — provider chain parity with sidebar", () => { - /** - * Extract provider component names used as JSX elements in a file. - * Matches `` patterns, returning the names. - */ - function extractProviders(content: string): string[] { - const matches = [...content.matchAll(/<(\w+Provider)\b/g)] - return [...new Set(matches.map((m) => m[1]!))] +describe("Shared webview provider shell", () => { + function ordered(source: string, names: string[]) { + const positions = names.map((name) => source.indexOf(`<${name}`)) + expect( + positions.every((position) => position >= 0), + `Missing provider from ${names.join(" -> ")}`, + ).toBe(true) + expect(positions).toEqual([...positions].sort((a, b) => a - b)) } - /** - * Providers that the agent manager intentionally omits because it does not - * use the components that depend on them. If a shared component (ChatView, - * MessageList, etc.) starts using one of these, the test will fail and - * force the developer to add the provider to AgentManagerApp.tsx. - */ - const KNOWN_EXCLUSIONS: string[] = [ - // These are wrapped by LanguageBridge and DataBridge respectively, - // which the agent manager already includes in its provider chain. - "LanguageProvider", - "DataProvider", - // Agent Manager owns its local session tabs and ChatView only reads this - // optional context in the standard sidebar/editor webview. - "LocalTabsProvider", - // Work-style onboarding is injected only into the sidebar empty state. - "WorkStyleProvider", - ] + it("owns the common provider order and bridges", () => { + const source = fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8") + ordered(source, [ + "ThemeProvider", + "DialogProvider", + "VSCodeProvider", + "MermaidDownloadBridge", + "ServerProvider", + "LanguageBridge", + "MarkedProvider", + "DiffComponentProvider", + "CodeComponentProvider", + "FileComponentProvider", + "ProviderProvider", + "ConfigProvider", + "SpeechToTextPrewarm", + "DisplayProvider", + "IndexingProvider", + "KiloEmbeddingModelsProvider", + "ImageModelsProvider", + "NotificationsProvider", + "SessionProvider", + "AgentRequirementsProvider", + "MemoryProvider", + "FeedbackProvider", + ]) + expect(source.indexOf("")) + }) - it("agent manager includes all context providers from sidebar App.tsx", () => { - const sidebar = fs.readFileSync(APP_FILE, "utf-8") - const agent = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8") + it("keeps sidebar-only providers in the sidebar root", () => { + const source = fs.readFileSync(APP_FILE, "utf-8") + ordered(source, [ + "ProviderShell.Root", + "WorkStyleProvider", + "ProviderShell.Session", + "LocalTabsProvider", + "ProviderShell.Chat", + "DataBridge", + "AppContent", + ]) + expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toMatch(/WorkStyleProvider|LocalTabsProvider/) + }) - const sidebarProviders = extractProviders(sidebar) - const agentProviders = extractProviders(agent) - const agentSet = new Set(agentProviders) - const excluded = new Set(KNOWN_EXCLUSIONS) - - const missing = sidebarProviders.filter((p) => !agentSet.has(p) && !excluded.has(p)) - - expect( - missing, - `These providers are in App.tsx but missing from AgentManagerApp.tsx.\n` + - `The agent manager reuses ChatView — any provider that ChatView's component\n` + - `tree depends on must be present in both provider chains.\n\n` + - `Missing providers:\n` + - missing.map((p) => ` - ${p}`).join("\n") + - `\n\nFix: add the missing <${missing[0]}> to AgentManagerApp.tsx's provider chain,\n` + - `or add it to KNOWN_EXCLUSIONS with a justification if it's truly unused.`, - ).toEqual([]) + it("keeps worktree mode in the Agent Manager root", () => { + const source = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8") + ordered(source, [ + "ProviderShell.Root", + "ProviderShell.Session", + "ProviderShell.Chat", + "WorktreeModeProvider", + "DataBridge", + "AgentManagerContent", + ]) + expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toContain("WorktreeModeProvider") }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts index 5575c78bb4..6ad70d26c8 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-close-session.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, mock } from "bun:test" const { AgentManagerProvider } = await import("../../src/agent-manager/AgentManagerProvider") type Manager = { + projectScope: { current: () => unknown; run: (ctx: unknown, fn: () => unknown) => unknown } + contexts: { active: () => unknown } connectionService: { getClient: () => unknown } panel: { postMessage: (message: unknown) => void @@ -61,6 +63,14 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool manager.getRoot = () => "/repo" manager.pushState = mock(() => undefined) manager.log = mock(() => undefined) + const ctx = { + peekState: () => (options?.state === false ? undefined : state), + root: "/repo", + stale: new Set(), + worktreeManager: () => ({}), + } + manager.projectScope = { current: () => ctx, run: (_: unknown, fn: () => unknown) => fn() } + manager.contexts = { active: () => ctx } return { manager, stopped, aborted, cleared, removed, messages, events } } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts new file mode 100644 index 0000000000..a387f16387 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-scope-state.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "bun:test" +import { composeDiffId, parseDiffId, scopeDescriptors } from "../../webview-ui/agent-manager/diff-scope-state" + +describe("agent-manager webview diff scope descriptors", () => { + it("offers the three git scopes without an active session", () => { + const descriptors = scopeDescriptors("wt_1") + expect(descriptors.map((d) => d.type)).toEqual(["workspace", "staged", "unstaged"]) + expect(descriptors.map((d) => d.id)).toEqual(["wt_1#branch", "wt_1#staged", "wt_1#unstaged"]) + }) + + it("adds the session scope with the active session embedded", () => { + const descriptors = scopeDescriptors("wt_1", "ses_abc") + expect(descriptors.map((d) => d.type)).toEqual(["workspace", "staged", "unstaged", "session"]) + const session = descriptors[3]! + expect(session.id).toBe("wt_1#session:ses_abc") + expect(session.group).toBe("Session") + expect(session.capabilities.revert).toBe(false) + }) + + it("embeds the active local session for the local context", () => { + const descriptors = scopeDescriptors("local", "ses_abc") + expect(descriptors[3]!.id).toBe("local#session:ses_abc") + }) + + it("round-trips the session descriptor id", () => { + expect(parseDiffId(composeDiffId("wt_1", "session", "ses_abc"))).toEqual({ + ctx: "wt_1", + scope: "session", + sessionId: "ses_abc", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts index ab0965fa7e..3b7477aafc 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n-split.test.ts @@ -39,6 +39,7 @@ import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr" import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl" import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk" import { dict as amIt } from "../../webview-ui/agent-manager/i18n/it" +import { dict as amFa } from "../../webview-ui/agent-manager/i18n/fa" const PREFIX = "agentManager." @@ -63,6 +64,7 @@ const locales = { nl: amNl, uk: amUk, it: amIt, + fa: amFa, } const appLocales = { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts new file mode 100644 index 0000000000..c35a6a53e9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test" +import { terminalChrome, terminalClosable, terminalStoppable } from "../../webview-ui/agent-manager/terminal/chrome" + +describe("Agent Manager Run terminal chrome", () => { + it("keeps the console icon for user terminals", () => { + expect(terminalChrome("Terminal 1", undefined)).toEqual({ icon: "console", tooltip: "Terminal 1" }) + }) + + it("renders compact status icons with accessible Run status details", () => { + expect(terminalChrome("Run", { state: "running", kind: "run" })).toEqual({ + icon: "spinner", + tooltip: "Run (Running)", + }) + expect(terminalChrome("Run", { state: "stopping", kind: "run" })).toEqual({ + icon: "spinner", + tooltip: "Run (Stopping)", + }) + expect(terminalChrome("Run", { state: "exited", exitCode: 0, kind: "run" })).toEqual({ + icon: "success", + tooltip: "Run (Exited, code 0)", + }) + expect(terminalChrome("Run", { state: "exited", exitCode: 1, kind: "run" })).toEqual({ + icon: "failure", + tooltip: "Run (Exited, code 1)", + }) + expect(terminalChrome("Run", { state: "failed", kind: "run" })).toEqual({ + icon: "failure", + tooltip: "Run (Failed)", + }) + }) + + it("keeps a running Setup tab unclosable until the script settles", () => { + expect(terminalClosable(undefined)).toBe(true) + expect(terminalClosable({ state: "running", kind: "run" })).toBe(true) + expect(terminalClosable({ state: "running", kind: "setup" })).toBe(false) + expect(terminalClosable({ state: "stopping", kind: "setup" })).toBe(false) + expect(terminalClosable({ state: "exited", exitCode: 0, kind: "setup" })).toBe(true) + expect(terminalClosable({ state: "exited", exitCode: 1, kind: "setup" })).toBe(true) + expect(terminalClosable({ state: "failed", kind: "setup" })).toBe(true) + }) + + it("offers a deliberate stop only while Setup is running", () => { + expect(terminalStoppable(undefined)).toBe(false) + expect(terminalStoppable({ state: "running", kind: "run" })).toBe(false) + expect(terminalStoppable({ state: "running", kind: "setup" })).toBe(true) + expect(terminalStoppable({ state: "stopping", kind: "setup" })).toBe(false) + expect(terminalStoppable({ state: "exited", exitCode: 1, kind: "setup" })).toBe(false) + expect(terminalStoppable({ state: "failed", kind: "setup" })).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts index 40330aa29c..9bc9472b49 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test" -import { affectsTerminalDestination, resolveTerminalDestination } from "../../src/agent-manager/terminal-destination" +import { + DestinationState, + affectsTerminalDestination, + resolveTerminalDestination, +} from "../../src/agent-manager/terminal-destination" function event(key: string) { return { @@ -19,4 +23,13 @@ describe("Agent Manager terminal destination", () => { expect(affectsTerminalDestination(event("kilo-code.new.agentManager.terminalButtonDestination"))).toBe(true) expect(affectsTerminalDestination(event("terminal.integrated.fontFamily"))).toBe(false) }) + + it("lets a panel-local choice beat later setting echoes", () => { + const state = new DestinationState("vscode") + state.sync("agentManager") + expect(state.value()).toBe("agentManager") + state.select("agentManager") + state.sync("vscode") + expect(state.value()).toBe("agentManager") + }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts index 64504d2600..02b7fffe27 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts @@ -53,6 +53,7 @@ describe("Agent Manager terminal font", () => { const message = new Promise((resolve) => { const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, @@ -77,6 +78,7 @@ describe("Agent Manager terminal font", () => { expect(created.font).toEqual(font) expect(created.worktreeId).toBeNull() expect(created.wsUrl).toContain("/pty/pty-1/connect") + expect(created.wsUrl).toContain("cursor=0") }) it("keeps the created font in terminal state", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts new file mode 100644 index 0000000000..f30ba800e5 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") + +test("xterm owns the padding used by FitAddon", () => { + const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] + const term = css.match(/\.am-terminal-host \[class~="xterm"\]\s*\{([^}]*)\}/)?.[1] + + expect(host).toBeDefined() + expect(term).toBeDefined() + expect(host).not.toMatch(/\bpadding\s*:/) + expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index bfa243d1fc..ef9bfcd6c4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -21,6 +21,7 @@ describe("Agent Manager terminal routing", () => { } as unknown as KiloClient const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined), @@ -78,6 +79,7 @@ describe("Agent Manager terminal routing", () => { } as unknown as KiloClient const router = new TerminalRouter({ getClient: () => client, + getClientAsync: async () => client, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), getRoot: () => "/workspace", getWorktreePath: () => undefined, @@ -108,4 +110,174 @@ describe("Agent Manager terminal routing", () => { await router.dispose() expect(removed).toContain("pty-new") }) + + it("fills numbering gaps left by closed terminals", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + let seq = 0 + const client = { + pty: { + create: async ({ title }: { title: string }) => { + titles.push(title) + seq++ + return { data: { id: `pty-${seq}`, title } } + }, + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + const create = (createId: string) => + router.handle({ type: "agentManager.terminal.create", createId, placement: "side", worktreeId: null }) + + create("one") + await wait() + create("two") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + + // Close "Terminal 1"; the next create reuses the freed number. + const first = messages.find((m) => m.type === "agentManager.terminal.created" && m.createId === "one") + if (first?.type !== "agentManager.terminal.created") throw new Error("missing created message") + router.handle({ type: "agentManager.terminal.close", terminalId: first.terminalId }) + await wait() + + create("three") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2", "Terminal 1"]) + await router.dispose() + }) + + it("hands out distinct numbers to concurrent creates", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + // Two creates before either settles must not share an ordinal. The + // backend-connection await defers the PTY creates to a microtask. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + resolvers[1]?.({ data: { id: "pty-b", title: titles[1]! } }) + await wait() + + // A failed create releases its reservation for the next attempt. + await router.dispose() + }) + + it("does not let a stale create release a new generation's reservation", async () => { + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: () => undefined, + getTerminalFont: () => font, + }) + + // Create A starts before the panel is recreated; its reservation dies + // with dispose(). Create B of the new generation reserves the same + // free number. When A's late completion settles, its release must not + // wipe B's reservation — otherwise create C would duplicate B's title. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + await router.dispose() + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + await wait() + router.handle({ type: "agentManager.terminal.create", createId: "c", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1", "Terminal 2"]) + await router.dispose() + }) + + it("awaits the shared backend connection before creating a terminal", async () => { + let connected = false + const client = { + pty: { + create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const messages: AgentManagerOutMessage[] = [] + const router = new TerminalRouter({ + getClient: () => { + if (!connected) throw new Error("Not connected") + return client + }, + getClientAsync: async () => { + await wait() + connected = true + return client + }, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "real", + placement: "tab", + worktreeId: null, + }) + expect(messages).toHaveLength(0) + await wait() + await wait() + + expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "real" }) + await router.dispose() + }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts index 199d3adca2..a3fbb00fec 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -1,32 +1,48 @@ import { describe, expect, it } from "bun:test" -import { createSideTerminal } from "../../webview-ui/agent-manager/terminal/side" +import { + createSideTerminal, + readSavedDestination, + resolveRunScriptRequest, + resolveVscodeTerminalRequest, +} from "../../webview-ui/agent-manager/terminal/side" -function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolean; focused?: boolean } = {}) { +function scene( + opts: { + destination?: "vscode" | "agentManager" + saved?: "vscode" | "agentManager" + visible?: boolean + focusedId?: string + mac?: boolean + } = {}, +) { const calls = { requestSide: 0, - closeSide: 0, + ensureSide: 0, + closed: [] as string[], hide: 0, refocus: 0, openVscode: 0, + persisted: [] as string[], posted: [] as Array>, tracked: [] as string[], } let visible = opts.visible ?? false - let focused = opts.focused ?? false + let focusedId = opts.focusedId as string | undefined const ctl = createSideTerminal({ handlers: { requestSide: () => { calls.requestSide++ visible = true }, - closeSide: () => { - calls.closeSide++ - visible = false + ensureSide: () => calls.ensureSide++, + closeSide: (terminalId) => { + calls.closed.push(terminalId) + focusedId = undefined return true }, }, visible: () => visible, - focused: () => focused, + focusedId: () => focusedId, hide: () => { calls.hide++ visible = false @@ -35,19 +51,22 @@ function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolea postMessage: (msg) => calls.posted.push(msg as Record), track: (button) => calls.tracked.push(button), openVscode: () => calls.openVscode++, + saved: opts.saved, + save: (destination) => calls.persisted.push(destination), + mac: opts.mac, }) - if (opts.destination) ctl.setDestination(opts.destination) + if (opts.destination) ctl.syncDefault(opts.destination) return { ctl, calls } } describe("Agent Manager side terminal controller", () => { it("toggles the panel and hands focus to the chat only when the terminal had it", () => { - const focused = scene({ destination: "agentManager", visible: true, focused: true }) + const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" }) focused.ctl.toggle() expect(focused.calls.hide).toBe(1) expect(focused.calls.refocus).toBe(1) - const elsewhere = scene({ destination: "agentManager", visible: true, focused: false }) + const elsewhere = scene({ destination: "agentManager", visible: true }) elsewhere.ctl.toggle() expect(elsewhere.calls.hide).toBe(1) expect(elsewhere.calls.refocus).toBe(0) @@ -58,15 +77,41 @@ describe("Agent Manager side terminal controller", () => { expect(hidden.calls.hide).toBe(0) }) - it("refocuses the chat after killing a focused terminal, not otherwise", () => { - const focused = scene({ focused: true }) - expect(focused.ctl.close()).toBe(true) - expect(focused.calls.closeSide).toBe(1) - expect(focused.calls.refocus).toBe(1) + it("ensures an open terminal panel has a terminal after switching contexts", async () => { + const visible = scene({ visible: true }) + visible.ctl.syncContext("wt-2", "wt-1") + await Promise.resolve() + expect(visible.calls.ensureSide).toBe(1) - const elsewhere = scene({ focused: false }) - expect(elsewhere.ctl.close()).toBe(true) - expect(elsewhere.calls.refocus).toBe(0) + visible.ctl.syncContext("wt-2", "wt-2") + visible.ctl.syncContext("wt-2", undefined) + await Promise.resolve() + expect(visible.calls.ensureSide).toBe(2) + expect(visible.calls.requestSide).toBe(0) + + const hidden = scene() + hidden.ctl.syncContext("wt-2", "wt-1") + expect(hidden.calls.ensureSide).toBe(0) + + const closed = scene({ visible: true }) + 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", () => { + 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) + }) + + it("does nothing on close without a focused terminal", () => { + const item = scene() + expect(item.ctl.close()).toBe(false) + expect(item.calls.closed).toEqual([]) + expect(item.calls.refocus).toBe(0) }) it("routes the primary action by destination", () => { @@ -81,12 +126,173 @@ describe("Agent Manager side terminal controller", () => { expect(panelFirst.calls.openVscode).toBe(0) }) + it("handles the platform terminal shortcut locally and dedupes the extension echo", () => { + const press = (opts: Partial = {}) => + ({ key: "/", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent + + // macOS: the workbench binding is Cmd+/, so only Cmd is accepted. + const mac = scene({ destination: "agentManager", mac: true }) + expect(mac.ctl.press(press({ metaKey: true }))).toBe(true) + expect(mac.calls.requestSide).toBe(1) + expect(mac.ctl.press(press({ ctrlKey: true }))).toBe(false) + expect(mac.ctl.press(press({ metaKey: true, ctrlKey: true }))).toBe(false) + expect(mac.calls.requestSide).toBe(1) + + // Windows/Linux: the workbench binding is Ctrl+/, so only Ctrl is accepted. + const win = scene({ destination: "agentManager", mac: false }) + expect(win.ctl.press(press({ ctrlKey: true }))).toBe(true) + expect(win.calls.requestSide).toBe(1) + expect(win.ctl.press(press({ metaKey: true }))).toBe(false) + expect(win.calls.requestSide).toBe(1) + + // Unrelated keys and modifier combinations are not the shortcut. + expect(win.ctl.press(press({ key: "?" }))).toBe(false) + expect(win.ctl.press(press({ ctrlKey: true, shiftKey: true }))).toBe(false) + expect(win.ctl.press(press({ ctrlKey: true, altKey: true }))).toBe(false) + expect(win.calls.requestSide).toBe(1) + + // The extension echoes each locally handled keypress back as an action + // message; one echo is consumed per press, then invocations run again. + expect(mac.ctl.echo()).toBe(true) + expect(mac.ctl.echo()).toBe(false) + }) + + it("consumes one echo per press, even for rapid repeated presses", () => { + const item = scene({ destination: "agentManager", mac: true }) + const press = () => item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + press() + press() + // Two presses toggled the panel open and closed again; both echoes + // must still be consumed so neither press toggles a third time. + expect(item.calls.requestSide).toBe(1) + expect(item.calls.hide).toBe(1) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(false) + }) + + it("drops a never-arriving echo after the timeout safety valve", async () => { + const item = scene({ destination: "agentManager", mac: true }) + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + await new Promise((resolve) => setTimeout(resolve, 550)) + expect(item.ctl.echo()).toBe(false) + expect(item.ctl.echo()).toBe(false) + }) + + it("expires a dropped echo's backlog at the next spaced press", async () => { + const item = scene({ destination: "agentManager", mac: true }) + // First press's echo never arrives (dropped forwarding); its backlog + // must not outlive the echo window into the next press. + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + await new Promise((resolve) => setTimeout(resolve, 550)) + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(false) + }) + it("persists the picked destination with a section-relative settings key", () => { const item = scene() item.ctl.choose("agentManager") expect(item.ctl.destination()).toBe("agentManager") expect(item.calls.posted).toEqual([ - { type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "agentManager" }, + { type: "agentManager.terminal.destinationSelected", destination: "agentManager" }, ]) + expect(item.calls.persisted).toEqual(["agentManager"]) + }) + + it("follows the remote default while the panel has no explicit choice", () => { + const item = scene() + item.ctl.syncDefault("agentManager") + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("vscode") + }) + + it("keeps the panel's explicit choice when another window rewrites the shared setting", () => { + const item = scene() + item.ctl.choose("agentManager") + // Echo of the application-scoped setting being rewritten elsewhere: + // worktree window B picked the VS Code terminal, which must not flip + // this panel's routing. + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("agentManager") + item.ctl.openPreferred("keyboard_shortcut") + expect(item.calls.requestSide).toBe(1) + expect(item.calls.openVscode).toBe(0) + }) + + it("restores a saved panel choice and ignores remote defaults", () => { + const item = scene({ saved: "agentManager" }) + expect(item.ctl.destination()).toBe("agentManager") + expect(item.calls.posted).toEqual([ + { type: "agentManager.terminal.destinationSelected", destination: "agentManager" }, + ]) + item.ctl.syncDefault("vscode") + expect(item.ctl.destination()).toBe("agentManager") + }) +}) + +describe("readSavedDestination", () => { + it("reads a valid choice and rejects anything else", () => { + expect(readSavedDestination({ terminalDestination: "agentManager" })).toBe("agentManager") + expect(readSavedDestination({ terminalDestination: "vscode" })).toBe("vscode") + expect(readSavedDestination({ terminalDestination: "bogus" })).toBeUndefined() + expect(readSavedDestination({})).toBeUndefined() + expect(readSavedDestination(undefined)).toBeUndefined() + }) +}) + +describe("resolveRunScriptRequest", () => { + it("carries the current panel dropdown destination with every Run request", () => { + expect(resolveRunScriptRequest("wt-1", "agentManager")).toEqual({ + type: "agentManager.runScript", + worktreeId: "wt-1", + destination: "agentManager", + }) + expect(resolveRunScriptRequest("local", "vscode")).toEqual({ + type: "agentManager.runScript", + worktreeId: "local", + destination: "vscode", + }) + }) +}) + +describe("resolveVscodeTerminalRequest", () => { + const sessions = new Map([ + ["wt-1", "session-a"], + ["wt-2", "session-b"], + ]) + const forWorktree = (id: string) => sessions.get(id) + + it("prefers the current session", () => { + expect(resolveVscodeTerminalRequest("wt-1", "session-current", forWorktree)).toEqual({ + type: "agentManager.showTerminal", + sessionId: "session-current", + }) + }) + + it("falls back to a session of the selected worktree when the current session is cleared", () => { + // Terminal tab activation clears the current session; the shortcut + // must still open a terminal for the worktree, not dead-end. + expect(resolveVscodeTerminalRequest("wt-2", undefined, forWorktree)).toEqual({ + type: "agentManager.showTerminal", + sessionId: "session-b", + }) + }) + + it("opens a worktree-rooted terminal for sessionless worktrees", () => { + expect(resolveVscodeTerminalRequest("wt-3", undefined, forWorktree)).toEqual({ + type: "agentManager.showWorktreeTerminal", + worktreeId: "wt-3", + }) + }) + + it("opens the local terminal for the local context and unassigned selections", () => { + expect(resolveVscodeTerminalRequest("local", undefined, forWorktree)).toEqual({ + type: "agentManager.showLocalTerminal", + }) + expect(resolveVscodeTerminalRequest(null, undefined, forWorktree)).toEqual({ + type: "agentManager.showLocalTerminal", + }) }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 432eed1fb6..ac3a8613b3 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -5,6 +5,7 @@ import { createTerminalHandlers, createTerminalMessageHandler, createTerminalState, + isTerminalTabId, } from "../../webview-ui/agent-manager/terminal/state" import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages" @@ -14,7 +15,14 @@ function scene(initial: string | null = LOCAL) { const [selection, setSelection] = createSignal(initial) const state = createTerminalState(selection) const posted: Array> = [] - const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], hidden: 0 } + const events = { + activated: [] as string[], + selected: [] as string[], + saved: 0, + shown: [] as string[], + errors: 0, + running: [] as Array<{ contextKey: string; terminalId: string }>, + } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ state, @@ -27,7 +35,6 @@ function scene(initial: string | null = LOCAL) { findTab: () => undefined, postMessage: (message) => posted.push(message as Record), onShowSide: (key) => events.shown.push(key), - onHideSide: () => events.hidden++, getSelection: selection, LOCAL, REVIEW_TAB_ID: "review", @@ -40,12 +47,48 @@ function scene(initial: string | null = LOCAL) { events.selected.push(value) setSelection(value) }, - showError: () => undefined, + showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), + onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } +function createdSide(createId: string, terminalId: string, title = "Terminal 1") { + return { + type: "agentManager.terminal.created", + createId, + placement: "side", + worktreeId: null, + terminalId, + title, + wsUrl: `ws://${terminalId}`, + font, + } satisfies ExtensionMessage +} + +function script( + terminalId: string, + state: "running" | "stopping" | "exited" | "failed" = "running", + exitCode?: number, +) { + return { + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId, + worktreeId: null, + kind: "run", + title: "Run", + wsUrl: `ws://${terminalId}`, + state, + ...(exitCode === undefined ? {} : { exitCode }), + font, + }, + ], + } satisfies ExtensionMessage +} + describe("Agent Manager terminal state", () => { it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { createRoot((dispose) => { @@ -64,21 +107,208 @@ describe("Agent Manager terminal state", () => { font, placement: "side", }) + item.state.setSideActive(LOCAL, "terminal:side") expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) expect(item.state.all().map((term) => term.id)).toEqual(["terminal:tab"]) expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"]) - expect(item.state.side()?.id).toBe("terminal:side") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") item.setSelection(null) expect(item.state.current()).toEqual([]) expect(item.state.sideKey()).toBe(LOCAL) - expect(item.state.side()?.id).toBe("terminal:side") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") dispose() }) }) - it("deduplicates side creation and reuses the terminal without tab side effects", () => { + it("hydrates complete Run snapshots without create ids and preserves mounted terminal records", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + const user = item.state.sidesForContext(LOCAL)[0]! + + expect(item.dispatch(script("script:run"))).toBe(true) + const run = item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run") + expect(run).toMatchObject({ title: "Run", placement: "side", kind: "run", contextKey: LOCAL }) + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "running", kind: "run" }) + expect(isTerminalTabId("script:run")).toBe(true) + + item.state.setTitle("script:run", "npm test") + expect(item.state.title("script:run")).toBe("Run") + + item.dispatch(script("script:run", "exited", 0)) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")).toBe(run) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0, kind: "run" }) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "terminal:user")).toBe(user) + // Existing snapshots update status only; they do not re-open the inspector. + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + + item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage) + expect(item.state.sidesForContext(LOCAL)).toEqual([user]) + expect(item.state.scriptStatus("script:run")).toBeUndefined() + dispose() + }) + }) + + it("maps Local Run snapshots to LOCAL and does not reveal exited terminals", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch(script("script:exit", "exited", 2)) + + expect(item.state.sidesForContext(LOCAL)[0]).toMatchObject({ id: "script:exit", contextKey: LOCAL }) + expect(item.events.running).toEqual([]) + dispose() + }) + }) + + it("hydrates Setup snapshots with their semantic title and kind", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:setup", + worktreeId: "wt-1", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + const setup = item.state.sidesForContext("wt-1").find((term) => term.id === "script:setup") + expect(setup).toMatchObject({ title: "Setup", placement: "side", kind: "setup", contextKey: "wt-1" }) + expect(item.events.running).toEqual([{ contextKey: "wt-1", terminalId: "script:setup" }]) + expect(item.state.scriptStatus("script:setup")).toEqual({ state: "running", kind: "setup" }) + expect(item.state.isScript("script:setup")).toBe(true) + + item.state.setTitle("script:setup", "bash") + expect(item.state.title("script:setup")).toBe("Setup") + + item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage) + expect(item.state.sidesForContext("wt-1")).toEqual([]) + expect(item.state.scriptStatus("script:setup")).toBeUndefined() + dispose() + }) + }) + + it("hydrates script terminals into independent project contexts", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:setup-a", + projectId: "prj-a", + worktreeId: "wt-1", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup-a", + state: "running", + font, + }, + { + terminalId: "script:setup-b", + projectId: "prj-b", + worktreeId: "wt-1", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup-b", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + expect(item.state.sidesForContext("prj-a:wt-1").map((term) => term.id)).toEqual(["script:setup-a"]) + expect(item.state.sidesForContext("prj-b:wt-1").map((term) => term.id)).toEqual(["script:setup-b"]) + expect(item.events.running).toEqual([ + { contextKey: "prj-a:wt-1", terminalId: "script:setup-a" }, + { contextKey: "prj-b:wt-1", terminalId: "script:setup-b" }, + ]) + dispose() + }) + }) + + it("activates a Setup terminal that hydrates before its worktree is selected", () => { + createRoot((dispose) => { + const item = scene(LOCAL) + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:setup-background", + worktreeId: "wt-background", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup-background", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + expect(item.state.sideActiveFor("wt-background")).toBe("script:setup-background") + expect(item.events.running).toEqual([{ contextKey: "wt-background", terminalId: "script:setup-background" }]) + dispose() + }) + }) + + it("does not replace an existing side-terminal selection during background hydration", () => { + createRoot((dispose) => { + const item = scene(LOCAL) + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:run-background", + worktreeId: "wt-background", + kind: "run", + title: "Run", + wsUrl: "ws://script:run-background", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + item.state.setSideActive("wt-background", "script:run-background") + + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:run-background", + worktreeId: "wt-background", + kind: "run", + title: "Run", + wsUrl: "ws://script:run-background", + state: "running", + font, + }, + { + terminalId: "script:setup-background", + worktreeId: "wt-background", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup-background", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + expect(item.state.sideActiveFor("wt-background")).toBe("script:run-background") + dispose() + }) + }) + + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() item.handlers.requestSide() @@ -88,18 +318,8 @@ describe("Agent Manager terminal state", () => { const request = item.posted[0]! expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) const createId = String(request.createId) - const created = { - type: "agentManager.terminal.created", - createId, - placement: "side", - worktreeId: null, - terminalId: "terminal:side", - title: "Terminal 1", - wsUrl: "ws://side", - font, - } satisfies ExtensionMessage - expect(item.dispatch(created)).toBe(true) - expect(item.state.side()?.id).toBe("terminal:side") + expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") expect(item.events.activated).toEqual([]) expect(item.events.selected).toEqual([]) expect(item.events.saved).toBe(0) @@ -111,6 +331,135 @@ describe("Agent Manager terminal state", () => { }) }) + it("ensures a side terminal without revealing the panel", () => { + createRoot((dispose) => { + const item = scene("wt-1") + item.handlers.ensureSide() + item.handlers.ensureSide() + + expect(item.events.shown).toEqual([]) + expect(item.posted).toHaveLength(1) + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "side", + worktreeId: "wt-1", + }) + dispose() + }) + }) + + it("supports several side terminals per context with newest active", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.addSide() + item.handlers.addSide() + expect(item.posted).toHaveLength(2) + const first = String(item.posted[0]!.createId) + const second = String(item.posted[1]!.createId) + + item.dispatch(createdSide(first, "terminal:one", "Terminal 1")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + + item.dispatch(createdSide(second, "terminal:two", "Terminal 2")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + dispose() + }) + }) + + it("switches the active side terminal on select", () => { + 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:two") + + item.handlers.selectSide("terminal:one") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.state.focusRequest()?.id).toBe("terminal:one") + dispose() + }) + }) + + it("moves activation to the last remaining side terminal on close", () => { + 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:two") + + expect(item.handlers.closeSide("terminal:two")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }]) + + expect(item.handlers.closeSide("terminal:one")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBeUndefined() + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + + // Closing an unknown or non-side id is a no-op. + expect(item.handlers.closeSide("terminal:gone")).toBe(false) + expect(item.posted).toHaveLength(2) + dispose() + }) + }) + + it("keeps only the target side terminal on close others", () => { + 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.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + // Another context must survive untouched: "others" is per context. + item.state.add("wt-1", { id: "terminal:other", title: "Other", wsUrl: "ws://other", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:one") + + item.handlers.closeSideOthers("terminal:two") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:two"]) + expect(item.state.sidesForContext("wt-1").map((term) => term.id)).toEqual(["terminal:other"]) + // The survivor becomes visible and focused, like selecting its tab. + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + expect(item.state.focusRequest()?.id).toBe("terminal:two") + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "terminal:one" }, + { type: "agentManager.terminal.close", terminalId: "terminal:three" }, + ]) + dispose() + }) + }) + + it("waits for Run closure confirmation while user terminal closes stay optimistic", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + item.dispatch(script("script:run")) + + expect(item.handlers.closeSide("script:run")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:user", "script:run"]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "script:run" }]) + + expect(item.handlers.closeSide("terminal:user")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["script:run"]) + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "script:run" }, + { type: "agentManager.terminal.close", terminalId: "terminal:user" }, + ]) + dispose() + }) + }) + + it("closes a stale side answer whose create request is unknown", () => { + createRoot((dispose) => { + const item = scene() + // A created message for a createId the webview never sent (e.g. it + // reloaded while the PTY was starting) must not leak the PTY. + item.dispatch(createdSide("stale-id", "terminal:stale")) + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:stale" }]) + dispose() + }) + }) + it("creates explicit terminal tabs independently of the side destination", () => { createRoot((dispose) => { const item = scene("wt-1") @@ -124,44 +473,91 @@ describe("Agent Manager terminal state", () => { }) }) - it("cancels a side terminal that is still starting", () => { + it("routes side creates of a worktree context to that worktree", () => { createRoot((dispose) => { - const item = scene() - item.handlers.requestSide() - const request = item.posted[0]! - expect(item.handlers.closeSide()).toBe(true) - expect(item.state.pendingSide(LOCAL)).toBeUndefined() - - item.dispatch({ - type: "agentManager.terminal.created", - createId: String(request.createId), + const item = scene("wt-1") + item.handlers.addSide() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", placement: "side", - worktreeId: null, - terminalId: "terminal:late", - title: "Terminal 1", - wsUrl: "ws://late", - font, + worktreeId: "wt-1", }) - expect(item.state.side()).toBeUndefined() - expect(item.posted.at(-1)).toEqual({ type: "agentManager.terminal.close", terminalId: "terminal:late" }) dispose() }) }) - it("closes a side terminal without changing the active chat tab", () => { + it("tracks OSC titles per terminal without touching the terminal records", () => { createRoot((dispose) => { const item = scene() - item.state.add(null, { - id: "terminal:side", - title: "Terminal 1", - wsUrl: "ws://side", - font, - placement: "side", - }) - expect(item.handlers.closeSide()).toBe(true) - expect(item.state.side()).toBeUndefined() - expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:side" }]) - expect(item.events.hidden).toBe(1) + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + const before = item.state.sidesForContext(LOCAL)[0]! + + item.state.setTitle("terminal:one", "npm run dev") + expect(item.state.title("terminal:one")).toBe("npm run dev") + // Reference stability: the stored record is untouched so does + // not remount the xterm instance on a title change. + expect(item.state.sidesForContext(LOCAL)[0]).toBe(before) + + // Empty titles are ignored; removal drops the override. + item.state.setTitle("terminal:one", " ") + expect(item.state.title("terminal:one")).toBe("npm run dev") + item.state.remove("terminal:one") + expect(item.state.title("terminal:one")).toBeUndefined() + dispose() + }) + }) + + it("reorders side terminals within their context via drag", () => { + 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.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 4", wsUrl: "ws://tab", font, placement: "tab" }) + + // Drag the first side terminal onto the third position. + expect(item.state.reorderSideDrag(LOCAL, "terminal:one", "terminal:three")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + // Tab terminals are untouched. + expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) + + // The order survives switching to another context and back. + item.setSelection("wt-1") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + item.setSelection(LOCAL) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + + // Unknown ids, tab-placement ids, and foreign contexts are rejected. + expect(item.state.reorderSideDrag(LOCAL, "terminal:gone", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag(LOCAL, "terminal:tab", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag("wt-1", "terminal:two", "terminal:three")).toBe(false) + dispose() + }) + }) + + it("reports the focused side terminal only for the current context", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:side", title: "Terminal 1", wsUrl: "ws://side", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 2", wsUrl: "ws://tab", font, placement: "tab" }) + + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:tab") + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:side") + expect(item.state.sideFocusedId()).toBe("terminal:side") dispose() }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts new file mode 100644 index 0000000000..5d2e5bc3e1 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs" +import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" + +const diff = (file: string, additions = 1): WorktreeFileDiff => ({ + file, + before: "", + after: "", + additions, + deletions: 0, +}) + +interface Sent { + type: string + sessionId?: string + file?: string +} + +// Only `postMessage` is exercised by the diff workflow, so a recording stub is +// enough — the signals and merge/pending logic under test are the real thing. +const vscode = (sent: Sent[]) => + ({ postMessage: (msg: Sent) => sent.push(msg) }) as unknown as Parameters[0] + +const withDiffs = (fn: (diffs: ReturnType, sent: Sent[]) => void) => { + createRoot((dispose) => { + const sent: Sent[] = [] + fn(createWorktreeDiffs(vscode(sent)), sent) + dispose() + }) +} + +describe("createWorktreeDiffs", () => { + it("stores full diffs per session", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + expect(diffs.diffDatas()["s1"]).toHaveLength(1) + }) + }) + + it("does not replace state when an update produces an identical diff list", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + const before = diffs.diffDatas() + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) + expect(diffs.diffDatas()).toBe(before) + }) + }) + + it("replaces a single file on a diffFile message and clears its pending flag", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts", 1)] }) + diffs.onWorktreeDiffFile({ + type: "agentManager.worktreeDiffFile", + sessionId: "s1", + file: "a.ts", + diff: diff("a.ts", 9), + }) + expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) + }) + }) + + it("tracks panel loading via diffLoading", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true }) + expect(diffs.diffLoading()).toBe(true) + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false }) + expect(diffs.diffLoading()).toBe(false) + }) + }) + + it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => { + withDiffs((diffs, sent) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.requestDiffFile("s1", "a.ts") + expect(sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile")).toHaveLength(1) + expect(diffs.diffFileLoadingFor(() => "s1").has("a.ts")).toBe(true) + }) + }) + + it("refreshStaleDiffs requests only files not already loading", () => { + withDiffs((diffs, sent) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.refreshStaleDiffs("s1", new Set(["a.ts", "b.ts"])) + const files = sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile").map((m) => m.file) + expect(files).toEqual(["a.ts", "b.ts"]) + }) + }) + + it("clears the session key once its last pending file resolves", () => { + withDiffs((diffs) => { + diffs.requestDiffFile("s1", "a.ts") + diffs.onWorktreeDiffFile({ + type: "agentManager.worktreeDiffFile", + sessionId: "s1", + file: "a.ts", + diff: diff("a.ts"), + }) + expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts new file mode 100644 index 0000000000..97cb9265f9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from "bun:test" +import { ProjectContexts } from "../../src/agent-manager/project/contexts" +import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project/registry" +import { projectIdFor } from "../../src/agent-manager/project/paths" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +const WORKSPACE = "/repo/main" +const PINNED = projectIdFor(WORKSPACE) + +function stored(id: string, trusted = false): StoredProject { + return { + id, + root: `/repo/${id}`, + order: 1, + trusted, + addedAt: new Date().toISOString(), + } +} + +function setup(opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[] } = {}) { + const registryProjects = opts.projects ?? [] + const registry = { + list: () => registryProjects, + get: (id: string) => registryProjects.find((p) => p.id === id), + } + const created: string[] = [] + const contexts = new ProjectContexts({ + workspaceRoot: () => opts.workspace, + registry, + trusted: (id) => registry.get(id)?.trusted === true, + enabled: () => opts.enabled ?? false, + deps: { + log: () => {}, + exists: () => true, + state: (root) => { + created.push(root) + return { root, flush: async () => {} } as unknown as WorktreeStateManager + }, + }, + }) + return { contexts, created, registryProjects } +} + +describe("ProjectContexts", () => { + it("derives the pinned project from the current workspace root", () => { + const { contexts } = setup({ workspace: WORKSPACE }) + const active = contexts.active() + expect(active?.id).toBe(PINNED) + expect(active?.root).toBe(WORKSPACE) + expect(active?.pinned).toBe(true) + }) + + it("has no active project without a workspace when the flag is off", () => { + const { contexts } = setup({ enabled: false }) + expect(contexts.active()).toBeUndefined() + }) + + it("falls back to the first trusted registry project without a workspace", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ enabled: true, projects: [extra] }) + expect(contexts.active()?.id).toBe("prj-extra") + }) + + it("rejects activating registry projects when the flag is off", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] }) + expect(contexts.activate("prj-extra")).toBeUndefined() + expect(contexts.active()?.id).toBe(PINNED) + }) + + it("rejects activating untrusted projects", () => { + const extra = stored("prj-extra", false) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + expect(contexts.activate("prj-extra")).toBeUndefined() + expect(contexts.active()?.id).toBe(PINNED) + }) + + it("activates trusted projects without changing accordion expansion", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + const ctx = contexts.activate("prj-extra") + expect(ctx?.root).toBe("/repo/prj-extra") + expect(contexts.active()?.id).toBe("prj-extra") + expect(contexts.isExpanded("prj-extra")).toBe(false) + }) + + it("keeps explicit accordion expansion unchanged after switching", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + contexts.active() + contexts.expand("prj-extra") + contexts.activate("prj-extra") + expect(contexts.active()?.id).toBe("prj-extra") + expect(contexts.isExpanded(PINNED)).toBe(true) + contexts.activate(PINNED) + expect(contexts.isExpanded("prj-extra")).toBe(true) + }) + + it("expands without changing the active project", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + expect(contexts.expand("prj-extra")?.id).toBe("prj-extra") + expect(contexts.active()?.id).toBe(PINNED) + expect(contexts.isExpanded("prj-extra")).toBe(true) + }) + + it("allows accordion collapse independently from active detail selection", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + contexts.activate("prj-extra") + contexts.collapse("prj-extra") + expect(contexts.isExpanded("prj-extra")).toBe(false) + expect(contexts.active()?.id).toBe("prj-extra") + }) + + it("removes projects and falls back to the pinned project", async () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + contexts.activate("prj-extra") + await contexts.remove("prj-extra") + expect(contexts.get("prj-extra")).toBeUndefined() + expect(contexts.active()?.id).toBe(PINNED) + }) + + it("initializes each context once while concurrent callers wait", async () => { + const { contexts } = setup({ workspace: WORKSPACE }) + const ctx = contexts.active()! + let release: (() => void) | undefined + const gate = new Promise((resolve) => (release = resolve)) + let calls = 0 + const run = async () => { + calls++ + await gate + return { ok: true, refsFixed: 0 } + } + const first = ctx.ensureReady(run) + const second = ctx.ensureReady(run) + expect(first).toBe(second) + expect(calls).toBe(1) + release!() + expect((await first).current).toBe(true) + expect(ctx.lifecycle).toBe("ready") + }) + + it("invalidates initialization when suspended", async () => { + const { contexts } = setup({ workspace: WORKSPACE }) + const ctx = contexts.active()! + let release: (() => void) | undefined + const gate = new Promise((resolve) => (release = resolve)) + const init = ctx.ensureReady(async () => { + await gate + return { ok: true, refsFixed: 0 } + }) + ctx.suspend() + release!() + expect((await init).current).toBe(false) + expect(ctx.lifecycle).toBe("suspended") + }) + + it("creates repository services lazily, once per context", () => { + const { contexts, created } = setup({ workspace: WORKSPACE }) + expect(created).toEqual([]) + const active = contexts.active()! + active.stateManager() + active.stateManager() + expect(created).toEqual([WORKSPACE]) + expect(active.loaded).toBe(true) + }) + + it("isolates services between projects", () => { + const extra = stored("prj-extra", true) + const { contexts, created } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + contexts.active()!.stateManager() + contexts.activate("prj-extra")!.stateManager() + expect(created).toEqual([WORKSPACE, "/repo/prj-extra"]) + }) + + it("re-derives the pinned project when the workspace changes", () => { + const ws: { root: string | undefined } = { root: undefined } + const { contexts } = setup({ enabled: false }) + const dynamic = new ProjectContexts({ + workspaceRoot: () => ws.root, + registry: { list: () => [], get: () => undefined }, + trusted: () => false, + enabled: () => false, + deps: { log: () => {}, exists: () => true }, + }) + expect(dynamic.active()).toBeUndefined() + ws.root = WORKSPACE + expect(dynamic.syncPinned()).toBe(true) + expect(dynamic.active()?.id).toBe(PINNED) + expect(dynamic.syncPinned()).toBe(false) + ws.root = "/repo/other" + expect(dynamic.syncPinned()).toBe(true) + expect(dynamic.active()?.root).toBe("/repo/other") + }) + + it("snapshots pinned first with registry projects in order", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + const list = contexts.snapshots() + expect(list.map((p) => p.id)).toEqual([PINNED, "prj-extra"]) + expect(list[0]!.pinned).toBe(true) + expect(list[0]!.active).toBe(true) + expect(list[0]!.trusted).toBe(true) + expect(list[1]!.label).toBe("prj-extra") + expect(list[1]!.initialized).toBe(false) + }) + + it("hides registry projects from snapshots when the flag is off", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] }) + expect(contexts.snapshots().map((p) => p.id)).toEqual([PINNED]) + }) + + it("returns active ownership to pinned Local when multi-project is disabled", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] }) + const secondary = contexts.expand("prj-extra")! + contexts.activate("prj-extra") + + const pinned = contexts.disable() + + expect(pinned?.id).toBe(PINNED) + expect(contexts.active()?.id).toBe(PINNED) + expect(contexts.isExpanded(PINNED)).toBe(true) + expect(contexts.isExpanded("prj-extra")).toBe(false) + expect(secondary.lifecycle).toBe("suspended") + }) + + it("dedupes registry entries that match the pinned project", () => { + const dupe = stored(PINNED, true) + const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [dupe] }) + expect(contexts.snapshots().map((p) => p.id)).toEqual([PINNED]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts new file mode 100644 index 0000000000..762268d78d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { execFileSync } from "child_process" +import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages" +import { ProjectRegistry } from "../../src/agent-manager/project/registry" +import { ProjectContexts } from "../../src/agent-manager/project/contexts" +import { projectIdFor } from "../../src/agent-manager/project/paths" +import type { AgentManagerInMessage } from "../../src/agent-manager/types" + +const WORKSPACE = "/repo/main" + +function gitRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-am-msg-")) + execFileSync("git", ["init", "-q", dir]) + return fs.realpathSync(dir) +} + +function setup(opts: { enabled?: boolean; workspace?: string } = {}) { + let stored: unknown + let pickResult: string | undefined + const registry = new ProjectRegistry({ + read: () => stored, + write: (value) => { + stored = value + }, + }) + const contexts = new ProjectContexts({ + workspaceRoot: () => opts.workspace ?? WORKSPACE, + registry, + trusted: (id) => registry.get(id)?.trusted === true, + enabled: () => opts.enabled ?? true, + deps: { log: () => {}, exists: (dir) => fs.existsSync(dir) }, + }) + const calls = { + activate: [] as string[], + expand: [] as string[], + push: 0, + error: [] as string[], + pick: 0, + ready: [] as string[], + readyResult: { ok: true, refsFixed: 0, current: true } as { ok: boolean; refsFixed: number; current: boolean }, + } + const deps: ProjectMessageDeps = { + registry, + contexts, + enabled: () => opts.enabled ?? true, + pickFolder: async () => { + calls.pick++ + return pickResult + }, + activate: (ctx) => calls.activate.push(ctx.id), + expand: (ctx) => calls.expand.push(ctx.id), + push: () => calls.push++, + error: (message) => calls.error.push(message), + ready: async (ctx) => { + calls.ready.push(ctx.id) + return calls.readyResult + }, + log: () => {}, + } + const pick = (dir: string | undefined) => { + pickResult = dir + } + return { registry, contexts, deps, calls, pick } +} + +function msg(type: string, extra: Record = {}): AgentManagerInMessage { + return { type, ...extra } as unknown as AgentManagerInMessage +} + +describe("handleProjectMessage", () => { + it("ignores non-project messages", async () => { + const { deps } = setup() + expect(await handleProjectMessage(msg("agentManager.createWorktree"), deps)).toBe(false) + }) + + it("pushes the catalog on requestProjects", async () => { + const { deps, calls, pick } = setup() + expect(await handleProjectMessage(msg("agentManager.requestProjects"), deps)).toBe(true) + expect(calls.push).toBe(1) + }) + + it("rejects every mutation while the experiment is disabled", async () => { + const { deps, calls } = setup({ enabled: false }) + for (const m of [ + msg("agentManager.addProject"), + msg("agentManager.removeProject", { projectId: "prj-x" }), + msg("agentManager.selectProject", { projectId: "prj-x" }), + msg("agentManager.setProjectExpanded", { projectId: "prj-x", expanded: true }), + msg("agentManager.trustProject", { projectId: "prj-x" }), + ]) { + await handleProjectMessage(m, deps) + } + expect(calls.pick).toBe(0) + expect(calls.activate).toEqual([]) + expect(calls.error.length).toBe(5) + }) + + it("adds a picked git repository to the registry", async () => { + const repo = gitRepo() + const { deps, registry, calls, pick } = setup() + pick(repo) + await handleProjectMessage(msg("agentManager.addProject"), deps) + const id = projectIdFor(repo) + const project = registry.get(id) + expect(project?.root).toBe(repo) + expect(project?.trusted).toBe(false) + expect(calls.push).toBe(1) + expect(calls.error).toEqual([]) + }) + + it("rejects folders outside a git repository", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-am-nogit-")) + const { deps, calls, registry, pick } = setup() + pick(dir) + await handleProjectMessage(msg("agentManager.addProject"), deps) + expect(registry.list()).toEqual([]) + expect(calls.error).toEqual(["The selected folder is not inside a Git repository."]) + }) + + it("rejects the pinned workspace repository", async () => { + const repo = gitRepo() + const { deps, calls, pick } = setup({ workspace: repo }) + pick(repo) + await handleProjectMessage(msg("agentManager.addProject"), deps) + expect(calls.error).toEqual(["That repository is already the workspace project."]) + }) + + it("rejects duplicate registration", async () => { + const repo = gitRepo() + const { deps, calls, pick } = setup() + pick(repo) + await handleProjectMessage(msg("agentManager.addProject"), deps) + await handleProjectMessage(msg("agentManager.addProject"), deps) + expect(calls.error).toEqual(["That repository is already registered as a project."]) + }) + + it("does nothing when the picker is cancelled", async () => { + const { deps, calls, registry, pick } = setup() + pick(undefined) + await handleProjectMessage(msg("agentManager.addProject"), deps) + expect(registry.list()).toEqual([]) + expect(calls.push).toBe(0) + }) + + it("blocks selecting an untrusted project until trusted", async () => { + const repo = gitRepo() + const { deps, registry, calls, pick } = setup() + const id = projectIdFor(repo) + await registry.add({ id, root: repo }) + await handleProjectMessage(msg("agentManager.selectProject", { projectId: id }), deps) + expect(calls.activate).toEqual([]) + expect(calls.error.length).toBe(1) + await handleProjectMessage(msg("agentManager.trustProject", { projectId: id }), deps) + await handleProjectMessage(msg("agentManager.selectProject", { projectId: id }), deps) + expect(calls.activate).toEqual([id]) + }) + + it("initializes trusted projects on expand", async () => { + const repo = gitRepo() + const { deps, registry, calls } = setup() + const id = projectIdFor(repo) + await registry.add({ id, root: repo }) + await registry.setTrusted(id, true) + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps) + expect(calls.expand).toEqual([id]) + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps) + expect(calls.expand).toEqual([id]) + }) + + it("does not initialize untrusted projects on expand", async () => { + const repo = gitRepo() + const { deps, registry, calls } = setup() + const id = projectIdFor(repo) + await registry.add({ id, root: repo }) + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps) + expect(calls.expand).toEqual([]) + }) + + it("removes projects without touching the pinned fallback", async () => { + const repo = gitRepo() + const { deps, registry, contexts, calls } = setup() + const id = projectIdFor(repo) + await registry.add({ id, root: repo }) + await registry.setTrusted(id, true) + await handleProjectMessage(msg("agentManager.selectProject", { projectId: id }), deps) + await handleProjectMessage(msg("agentManager.removeProject", { projectId: id }), deps) + expect(registry.get(id)).toBeUndefined() + expect(contexts.get(id)).toBeUndefined() + expect(contexts.active()?.root).toBe(WORKSPACE) + expect(calls.activate).toEqual([id]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-paths.test.ts b/packages/kilo-vscode/tests/unit/agent-project-paths.test.ts new file mode 100644 index 0000000000..6ad6b63bec --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-paths.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { + canonicalizePath, + findTrackedBranch, + resolveProjectRoot, + samePath, +} from "../../src/agent-manager/project/paths" + +describe("project-paths", () => { + it("canonicalizePath resolves a symlink alias to its realpath", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-root-")) + const target = fs.mkdtempSync(path.join(os.tmpdir(), "pp-target-")) + const alias = path.join(root, "alias") + fs.symlinkSync(target, alias) + try { + expect(canonicalizePath(alias)).toBe(canonicalizePath(target)) + expect(canonicalizePath(alias)).not.toBe(alias) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(target, { recursive: true, force: true }) + } + }) + + it("samePath folds case on darwin/win32 and matches exactly on linux", () => { + expect(samePath("/x/Y", "/x/y", "darwin")).toBe(true) + expect(samePath("/x/Y", "/x/y", "win32")).toBe(true) + expect(samePath("/x/Y", "/x/y", "linux")).toBe(false) + expect(samePath("/x/y", "/x/y", "linux")).toBe(true) + }) + + it("findTrackedBranch matches a symlink-aliased path against realpath keys", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-tracked-")) + const target = fs.mkdtempSync(path.join(os.tmpdir(), "pp-target-")) + const alias = path.join(root, "alias") + fs.symlinkSync(target, alias) + try { + // git worktree list --porcelain realpath-resolves registration, so the + // tracked key is the realpath, not the lexical alias a session was + // registered with. + const tracked = new Map([[canonicalizePath(target), "branch-a"]]) + expect(findTrackedBranch(tracked, alias)).toBe("branch-a") + expect(findTrackedBranch(tracked, target)).toBe("branch-a") + } finally { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(target, { recursive: true, force: true }) + } + }) + + it("findTrackedBranch matches a lexical-keyed map by canonicalizing both sides", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-lexical-")) + const target = fs.mkdtempSync(path.join(os.tmpdir(), "pp-lexical-target-")) + const alias = path.join(root, "alias") + fs.symlinkSync(target, alias) + try { + // A map keyed by the lexical alias (as older code paths produced) must + // still match a probe path that canonicalizes to the same realpath. + const tracked = new Map([[alias, "branch-a"]]) + expect(findTrackedBranch(tracked, target)).toBe("branch-a") + } finally { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(target, { recursive: true, force: true }) + } + }) + + it("findTrackedBranch returns undefined for an untracked existing path", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-untracked-")) + const other = path.join(root, "other") + fs.mkdirSync(other, { recursive: true }) + try { + const tracked = new Map([[canonicalizePath(root), "branch-a"]]) + expect(findTrackedBranch(tracked, other)).toBeUndefined() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it("resolveProjectRoot maps a linked worktree to the primary checkout", async () => { + const calls = new Map([ + ["rev-parse --path-format=absolute --show-toplevel", "/repo/worktree"], + ["rev-parse --path-format=absolute --git-dir", "/repo/.git/worktrees/feature"], + ["rev-parse --path-format=absolute --git-common-dir", "/repo/.git"], + ["worktree list --porcelain -z", "worktree /repo\0HEAD abc\0\0worktree /repo/worktree\0HEAD def\0"], + ]) + const root = await resolveProjectRoot("/repo/worktree", async (_cwd, args) => calls.get(args.join(" ")) ?? "") + + expect(root).toBe(canonicalizePath("/repo")) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts b/packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts new file mode 100644 index 0000000000..30b0454c22 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "bun:test" +import { ProjectPollers, type PollerPair, type StatsOutMessage } from "../../src/agent-manager/project/pollers" +import { ProjectContexts } from "../../src/agent-manager/project/contexts" +import type { StoredProject } from "../../src/agent-manager/project/registry" +import { projectIdFor } from "../../src/agent-manager/project/paths" +import type { GitOps } from "../../src/agent-manager/GitOps" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +const WORKSPACE = "/repo/main" +const PINNED = projectIdFor(WORKSPACE) + +function stored(id: string, trusted = true): StoredProject { + return { id, root: `/repo/${id}`, order: 1, trusted, addedAt: new Date().toISOString() } +} + +function setup(projects: StoredProject[], opts: { enabled?: boolean } = {}) { + const contexts = new ProjectContexts({ + workspaceRoot: () => WORKSPACE, + registry: { + list: () => projects, + get: (id) => projects.find((p) => p.id === id), + }, + trusted: (id) => projects.find((p) => p.id === id)?.trusted === true, + enabled: () => opts.enabled ?? true, + deps: { + log: () => {}, + exists: () => true, + state: () => ({ flush: async () => {} }) as unknown as WorktreeStateManager, + } as never, + }) + return contexts +} + +/** Expand a project and mark its state as initialized (as initExpanded would). */ +function expand(contexts: ProjectContexts, id: string, ready = true): void { + contexts.expand(id) + if (ready) contexts.get(id)?.stateManager() +} + +interface FakePair { + pair: PollerPair + enabled: { stats: boolean; pr: boolean } + visible: { stats: boolean; pr: boolean } + stopped: { stats: boolean; pr: boolean } +} + +function fakes() { + const posted: StatsOutMessage[] = [] + const made = new Map() + const create = (ctx: { id: string }): PollerPair => { + const rec: FakePair = { + pair: { + stats: { + setEnabled: (v) => (rec.enabled.stats = v), + setVisible: (v) => (rec.visible.stats = v), + stop: () => (rec.stopped.stats = true), + }, + pr: { + poller: { + setEnabled: (v) => (rec.enabled.pr = v), + setVisible: (v) => (rec.visible.pr = v), + stop: () => (rec.stopped.pr = true), + }, + }, + }, + enabled: { stats: false, pr: false }, + visible: { stats: true, pr: true }, + stopped: { stats: false, pr: false }, + } + made.set(ctx.id, rec) + return rec.pair + } + const deps = { + git: {} as GitOps, + semaphore: undefined as never, + localDiff: async () => [], + post: (msg: StatsOutMessage) => posted.push(msg), + openExternal: () => {}, + visible: () => true, + log: () => {}, + } + return { posted, made, create, deps } +} + +describe("ProjectPollers", () => { + it("starts pollers for an expanded trusted background project", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra") + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + const rec = made.get("prj-extra") + expect(rec).toBeDefined() + expect(rec!.enabled).toEqual({ stats: true, pr: true }) + }) + + it("does not start pollers for the active project", () => { + const contexts = setup([]) + contexts.active() + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + expect(made.has(PINNED)).toBe(false) + }) + + it("stops pollers when a project is collapsed", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra") + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + contexts.collapse("prj-extra") + pollers.sync(contexts) + const rec = made.get("prj-extra") + expect(rec!.stopped).toEqual({ stats: true, pr: true }) + }) + + it("stops pollers when a project is removed", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra") + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + contexts.remove("prj-extra") + pollers.sync(contexts) + expect(made.get("prj-extra")!.stopped.stats).toBe(true) + }) + + it("skips projects whose state is not initialized yet", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra", false) + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + expect(made.has("prj-extra")).toBe(false) + }) + + it("does not duplicate pollers on repeated syncs", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra") + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + pollers.sync(contexts) + expect(made.size).toBe(1) + }) + + it("stops pollers for a background project that becomes active", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra") + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + contexts.activate("prj-extra") + pollers.sync(contexts) + expect(made.get("prj-extra")!.stopped.stats).toBe(true) + }) + + it("disposes all pollers", () => { + const extra = stored("prj-extra") + const contexts = setup([extra]) + expand(contexts, "prj-extra") + const { made, create, deps } = fakes() + const pollers = new ProjectPollers(deps, (ctx) => create(ctx)) + pollers.sync(contexts) + pollers.dispose() + expect(made.get("prj-extra")!.stopped).toEqual({ stats: true, pr: true }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts b/packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts new file mode 100644 index 0000000000..c4064d8523 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test" +import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context" +import { reactivateProject } from "../../src/agent-manager/project/init" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +const ROOT = "/repo/main" + +function fakeState(): WorktreeStateManager { + return { + getWorktrees: () => [], + getSessions: () => [], + flush: async () => {}, + } as unknown as WorktreeStateManager +} + +function makeContext(): ProjectContext { + const deps: ProjectContextDeps = { log: () => {}, state: () => fakeState() } + const ctx = new ProjectContext("prj-test", ROOT, false, deps) + ctx.stateManager() + return ctx +} + +describe("reactivateProject", () => { + it("returns false for a cold context that never initialized", () => { + const ctx = makeContext() + const pushed: string[] = [] + expect(reactivateProject(ctx, undefined, () => pushed.push(ctx.id))).toBe(false) + expect(pushed).toHaveLength(0) + }) + + it("re-registers and pushes in-memory state for a ready context", async () => { + const ctx = makeContext() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + const registered = new Map() + const pushed: string[] = [] + const ok = reactivateProject( + ctx, + { + setSessionDirectory: (id, dir) => registered.set(id, dir), + trackSession: () => {}, + }, + () => pushed.push(ctx.id), + ) + expect(ok).toBe(true) + expect(pushed).toEqual(["prj-test"]) + }) + + it("returns false after the context is suspended", async () => { + const ctx = makeContext() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + ctx.suspend() + expect(reactivateProject(ctx, undefined, () => {})).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-registry.test.ts b/packages/kilo-vscode/tests/unit/agent-project-registry.test.ts new file mode 100644 index 0000000000..059bd9f7df --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-registry.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { createProjectRegistry } from "../../webview-ui/agent-manager/project/registry" + +const pending = (id: string) => id.startsWith("pending") + +function setup(active: string, persisted = {}) { + let id = active + const registry = createProjectRegistry({ persisted, activeId: () => id }) + return { registry, set: (next: string) => (id = next) } +} + +describe("createProjectRegistry", () => { + it("keeps each project's tabs to itself", () => + createRoot((dispose) => { + const { registry, set } = setup("prj-a") + registry.active().tabs.set(["ses_a1", "ses_a2"]) + set("prj-b") + expect(registry.active().tabs.ids()).toEqual([]) + registry.active().tabs.set(["ses_b1"]) + set("prj-a") + expect(registry.active().tabs.ids()).toEqual(["ses_a1", "ses_a2"]) + dispose() + })) + + it("migrates legacy single-project tabs into the first real project", () => + createRoot((dispose) => { + const { registry, set } = setup("single", { localSessionIDs: ["ses_old"] }) + expect(registry.active().tabs.ids()).toEqual(["ses_old"]) + set("prj-a") + expect(registry.active().tabs.ids()).toEqual(["ses_old"]) + expect(registry.all().map((s) => s.id)).toEqual(["prj-a"]) + dispose() + })) + + it("restores persisted buckets per project", () => + createRoot((dispose) => { + const { registry, set } = setup("prj-b", { localTabs: { "prj-a": ["ses_a1"], "prj-b": ["ses_b1"] } }) + expect(registry.active().tabs.ids()).toEqual(["ses_b1"]) + set("prj-a") + expect(registry.active().tabs.ids()).toEqual(["ses_a1"]) + dispose() + })) + + it("migrates legacy tabs only once, into the first real project", () => + createRoot((dispose) => { + const { registry, set } = setup("single", { localSessionIDs: ["ses_old"] }) + set("prj-a") + expect(registry.active().tabs.ids()).toEqual(["ses_old"]) + set("prj-b") + // "single" was consumed by the first migration, so prj-b stays empty. + expect(registry.active().tabs.ids()).toEqual([]) + dispose() + })) + + it("does not migrate single-project tabs over existing ones", () => + createRoot((dispose) => { + const { registry, set } = setup("single", { + localSessionIDs: ["ses_old"], + localTabs: { "prj-a": ["ses_a1"] }, + }) + set("prj-a") + expect(registry.active().tabs.ids()).toEqual(["ses_a1"]) + expect(registry.all().some((s) => s.id === "single")).toBe(true) + dispose() + })) + + it("bumps version when a store is created", () => + createRoot((dispose) => { + const { registry } = setup("prj-a") + const before = registry.version() + registry.ensure("prj-b") + expect(registry.version()).toBeGreaterThan(before) + dispose() + })) + + it("prunes stores for removed projects but keeps single", () => + createRoot((dispose) => { + const { registry } = setup("prj-a") + registry.ensure("prj-a") + registry.ensure("prj-b") + registry.ensure("single") + registry.prune(new Set(["prj-a"])) + expect( + registry + .all() + .map((s) => s.id) + .sort(), + ).toEqual(["prj-a", "single"]) + dispose() + })) + + it("keeps tab memory per project", () => + createRoot((dispose) => { + const { registry, set } = setup("prj-a") + registry.active().tabMemory.set("local", "ses_a1") + set("prj-b") + expect(registry.active().tabMemory.get("local")).toBeUndefined() + registry.active().tabMemory.set("local", "ses_b1") + set("prj-a") + expect(registry.active().tabMemory.get("local")).toBe("ses_a1") + dispose() + })) + + it("strips pending drafts from durable output", () => + createRoot((dispose) => { + const { registry } = setup("prj-a") + registry.active().tabs.set(["ses_a1", "pending-1"]) + expect(registry.active().tabs.durable(pending)).toEqual(["ses_a1"]) + dispose() + })) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts new file mode 100644 index 0000000000..0b050648a0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test" +import { rememberTarget, restoreProjectTarget, type RestoreDeps } from "../../webview-ui/agent-manager/project/restore" +import type { AgentManagerStateMessage } from "../../webview-ui/src/types/messages" + +function state(over: Partial): AgentManagerStateMessage { + return { + type: "agentManager.state", + projectId: "prj-a", + worktrees: [], + sessions: [], + ...over, + } +} + +function deps() { + const calls: Record = {} + const record = + (key: string) => + (...args: unknown[]) => { + calls[key] = [...(calls[key] ?? []), ...(args.length > 0 ? args : [true])] + } + const impl: RestoreDeps = { + selectLocal: () => record("local")(), + selectWorktree: (id) => record("worktree")(id), + selectSession: (id) => record("session")(id), + focusManaged: (wt, sid) => record("managed")(wt, sid), + setSelection: (id) => record("selection")(id), + setActivePendingId: (id) => record("pending")(id), + } + return { calls, impl } +} + +describe("restoreProjectTarget", () => { + it("restores a persisted worktree target", () => { + const { calls, impl } = deps() + restoreProjectTarget( + state({ + worktrees: [{ id: "0" } as never], + activeTarget: { projectId: "prj-a", kind: "worktree", worktreeId: "0" }, + }), + impl, + ) + expect(calls.worktree).toEqual(["0"]) + expect(calls.pending).toEqual([undefined]) + }) + + it("restores a managed worktree session target", () => { + const { calls, impl } = deps() + restoreProjectTarget( + state({ + worktrees: [{ id: "0" } as never], + sessions: [{ id: "ses-1", worktreeId: "0" } as never], + activeTarget: { projectId: "prj-a", kind: "session", sessionId: "ses-1" }, + }), + impl, + ) + expect(calls.managed).toEqual(["0", "ses-1"]) + }) + + it("restores a local session target via Local + select", () => { + const { calls, impl } = deps() + restoreProjectTarget(state({ activeTarget: { projectId: "prj-a", kind: "session", sessionId: "ses-x" } }), impl) + expect(calls.selection).toEqual(["local"]) + expect(calls.session).toEqual(["ses-x"]) + }) + + it("falls back to Local when the target no longer exists", () => { + const { calls, impl } = deps() + restoreProjectTarget(state({ activeTarget: { projectId: "prj-a", kind: "worktree", worktreeId: "gone" } }), impl) + expect(calls.local).toHaveLength(1) + expect(calls.worktree).toBeUndefined() + }) + + it("falls back to Local when there is no target", () => { + const { calls, impl } = deps() + restoreProjectTarget(state({}), impl) + expect(calls.local).toHaveLength(1) + }) +}) + +describe("rememberTarget", () => { + it("posts a session target when a session is active", () => { + const posted: unknown[] = [] + rememberTarget((msg) => posted.push(msg), "prj-a", "0", "ses-1") + expect(posted).toEqual([ + { + type: "agentManager.rememberTarget", + projectId: "prj-a", + target: { projectId: "prj-a", kind: "session", sessionId: "ses-1" }, + }, + ]) + }) + + it("posts a worktree target when no session is active", () => { + const posted: unknown[] = [] + rememberTarget((msg) => posted.push(msg), "prj-a", "0", undefined) + expect(posted).toEqual([ + { + type: "agentManager.rememberTarget", + projectId: "prj-a", + target: { projectId: "prj-a", kind: "worktree", worktreeId: "0" }, + }, + ]) + }) + + it("posts a local target for the Local context", () => { + const posted: unknown[] = [] + rememberTarget((msg) => posted.push(msg), "prj-a", "local", undefined) + expect(posted).toEqual([ + { type: "agentManager.rememberTarget", projectId: "prj-a", target: { projectId: "prj-a", kind: "local" } }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-route.test.ts b/packages/kilo-vscode/tests/unit/agent-project-route.test.ts new file mode 100644 index 0000000000..9ee54dc8fc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-route.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test" +import { ProjectRouteError, ProjectRouteService } from "../../src/agent-manager/project/route" + +describe("ProjectRouteService", () => { + it("resolves explicit local, worktree, and session routes", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerWorktree({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1) + routes.registerSession({ projectId: "a", sessionId: "local" }, "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "work" }, "/repo/a/.kilo/wt", 1) + expect(routes.projectRoot({ projectId: "a" })).toBe("/repo/a") + expect(routes.worktreeDirectory({ projectId: "a", worktreeId: "wt" })).toBe("/repo/a/.kilo/wt") + expect(routes.sessionDirectory({ projectId: "a", sessionId: "local" })).toBe("/repo/a") + expect(routes.sessionDirectory({ projectId: "a", sessionId: "work" })).toBe("/repo/a/.kilo/wt") + }) + + it("inherits child session ownership", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "parent" }, "/repo/a", 1) + routes.inheritSession({ projectId: "a", sessionId: "child" }, { projectId: "a", sessionId: "parent" }) + expect(routes.sessionDirectory({ projectId: "a", sessionId: "child" })).toBe("/repo/a") + }) + + it("detects ambiguous raw session ids", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + expect(() => routes.resolveRawSession("same")).toThrow(ProjectRouteError) + try { + routes.resolveRawSession("same") + } catch (err) { + expect((err as ProjectRouteError).code).toBe("session_ambiguous") + } + }) + + it("invalidates routes when a project generation changes", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "s" }, "/repo/a", 1) + routes.registerProject("a", "/repo/a", 2) + expect(() => routes.sessionDirectory({ projectId: "a", sessionId: "s" })).toThrow(ProjectRouteError) + }) + + it("uses composite UI keys", () => { + expect(ProjectRouteService.key({ projectId: "a", worktreeId: "same" })).not.toBe( + ProjectRouteService.key({ projectId: "b", worktreeId: "same" }), + ) + }) + + describe("safe resolution (non-throwing)", () => { + it("trySessionDirectory returns the exact dir for an unambiguous raw id", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1) + expect(routes.trySessionDirectory("s1")).toBe("/repo/a") + }) + + it("trySessionDirectory returns undefined for an unknown raw id", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + expect(routes.trySessionDirectory("missing")).toBeUndefined() + }) + + it("trySessionDirectory returns undefined for an ambiguous raw id (never picks one)", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + expect(routes.trySessionDirectory("same")).toBeUndefined() + }) + + it("isSessionAmbiguous is true only when a raw id maps to more than one project", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + expect(routes.isSessionAmbiguous("same")).toBe(false) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + expect(routes.isSessionAmbiguous("same")).toBe(true) + expect(routes.isSessionAmbiguous("unknown")).toBe(false) + }) + + it("trySessionDirectoryFor resolves a project-qualified ref exactly", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + expect(routes.trySessionDirectoryFor({ projectId: "a", sessionId: "same" })).toBe("/repo/a") + expect(routes.trySessionDirectoryFor({ projectId: "b", sessionId: "same" })).toBe("/repo/b") + }) + + it("trySessionDirectoryFor returns undefined for an unknown project or session", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1) + expect(routes.trySessionDirectoryFor({ projectId: "nope", sessionId: "s1" })).toBeUndefined() + expect(routes.trySessionDirectoryFor({ projectId: "a", sessionId: "missing" })).toBeUndefined() + }) + + it("unregisterSession removes one route and clears ambiguity", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + expect(routes.isSessionAmbiguous("same")).toBe(true) + routes.unregisterSession({ projectId: "b", sessionId: "same" }) + expect(routes.isSessionAmbiguous("same")).toBe(false) + expect(routes.trySessionDirectory("same")).toBe("/repo/a") + }) + + it("unregisterSession is a no-op for an unknown ref", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + expect(() => routes.unregisterSession({ projectId: "a", sessionId: "missing" })).not.toThrow() + }) + + it("hasSession reports registered refs only", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1) + expect(routes.hasSession({ projectId: "a", sessionId: "s1" })).toBe(true) + expect(routes.hasSession({ projectId: "a", sessionId: "s2" })).toBe(false) + }) + + it("unregisterProject clears all session routes for that project", () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "s2" }, "/repo/a", 1) + routes.unregisterProject("a") + expect(routes.trySessionDirectory("s1")).toBeUndefined() + expect(routes.trySessionDirectory("s2")).toBeUndefined() + expect(routes.isSessionAmbiguous("s1")).toBe(false) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-run-status-webview.test.ts b/packages/kilo-vscode/tests/unit/agent-project-run-status-webview.test.ts new file mode 100644 index 0000000000..8af28bc609 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-run-status-webview.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { applyRunStatus } from "../../webview-ui/agent-manager/project/run-status" +import { createProjectRegistry } from "../../webview-ui/agent-manager/project/registry" + +const pending = (id: string) => id.startsWith("pending") + +function setup(active: string) { + let id = active + const registry = createProjectRegistry({ persisted: {}, activeId: () => id }) + return { + registry, + set: (next: string) => (id = next), + deps: { ensure: (pid: string) => registry.ensure(pid), active: () => registry.active() }, + } +} + +describe("applyRunStatus", () => { + it("routes project-stamped statuses to their own store", () => + createRoot((dispose) => { + const { registry, set, deps } = setup("prj-a") + registry.ensure("prj-b") + + expect( + applyRunStatus( + { type: "agentManager.runStatus", worktreeId: "local", state: "running", projectId: "prj-b" }, + deps, + ), + ).toBe(true) + + expect(registry.ensure("prj-b").runStatuses()["local"]?.state).toBe("running") + expect(registry.active().runStatuses()["local"]).toBeUndefined() + + set("prj-b") + expect(registry.active().runStatuses()["local"]?.state).toBe("running") + dispose() + })) + + it("routes unstamped statuses to the active store (legacy behavior)", () => + createRoot((dispose) => { + const { registry, deps } = setup("prj-a") + registry.ensure("prj-b") + + expect(applyRunStatus({ type: "agentManager.runStatus", worktreeId: "local", state: "running" }, deps)).toBe(true) + + expect(registry.active().runStatuses()["local"]?.state).toBe("running") + expect(registry.ensure("prj-b").runStatuses()["local"]).toBeUndefined() + dispose() + })) + + it("ignores other message types", () => + createRoot((dispose) => { + const { deps } = setup("prj-a") + expect(applyRunStatus({ type: "agentManager.localStats" }, deps)).toBe(false) + dispose() + })) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-run-status.test.ts b/packages/kilo-vscode/tests/unit/agent-project-run-status.test.ts new file mode 100644 index 0000000000..861fd29ca8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-run-status.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, mock } from "bun:test" +import { AgentManagerProvider } from "../../src/agent-manager/AgentManagerProvider" +import type { ProjectContext } from "../../src/agent-manager/project/context" +import type { RunStatus } from "../../src/agent-manager/run/manager" +import type { AgentManagerOutMessage } from "../../src/agent-manager/types" + +type Internals = { + run: { state: () => { runStatuses: RunStatus[]; runScriptConfigured: boolean } } + contexts: { + resolve: (id: string) => { id: string } | undefined + byWorktree: (id: string) => { id: string } | undefined + } + postToWebview: (message: AgentManagerOutMessage) => void + runStateFor: (ctx: ProjectContext) => { runStatuses: RunStatus[] } + postRunMessage: (message: AgentManagerOutMessage) => void +} + +function ctx(id: string, pinned: boolean, worktreeIds: string[]): ProjectContext { + return { + id, + pinned, + peekState: () => ({ getWorktrees: () => worktreeIds.map((wt) => ({ id: wt })) }), + } as unknown as ProjectContext +} + +function provider(runStatuses: RunStatus[]) { + const instance = Object.create(AgentManagerProvider.prototype) as Internals + instance.run = { state: () => ({ runStatuses, runScriptConfigured: true }) } + return instance +} + +describe("Agent Manager run status project scoping", () => { + it("filters the state payload to the target project and un-namespaces its local key", () => { + const instance = provider([ + { worktreeId: "wt-a1", state: "running" }, + { worktreeId: "prj-a:local", state: "running" }, + { worktreeId: "wt-b1", state: "running" }, + { worktreeId: "prj-b:local", state: "stopping" }, + ]) + + const a = instance.runStateFor(ctx("prj-a", false, ["wt-a1"])) + expect(a.runStatuses).toEqual([ + { worktreeId: "wt-a1", state: "running" }, + { worktreeId: "local", state: "running" }, + ]) + + const b = instance.runStateFor(ctx("prj-b", false, ["wt-b1"])) + expect(b.runStatuses).toEqual([ + { worktreeId: "wt-b1", state: "running" }, + { worktreeId: "local", state: "stopping" }, + ]) + }) + + it("keeps legacy unqualified local entries on the pinned project only", () => { + const instance = provider([{ worktreeId: "local", state: "running" }]) + + expect(instance.runStateFor(ctx("prj-pinned", true, [])).runStatuses).toEqual([ + { worktreeId: "local", state: "running" }, + ]) + expect(instance.runStateFor(ctx("prj-other", false, [])).runStatuses).toEqual([]) + }) + + it("stamps the owning project on emissions and un-namespaces qualified local keys", () => { + const instance = provider([]) + const posted: AgentManagerOutMessage[] = [] + instance.contexts = { + resolve: (id) => (id === "prj-a" ? { id: "prj-a" } : undefined), + byWorktree: (id) => (id === "wt-a1" ? { id: "prj-a" } : undefined), + } + instance.postToWebview = mock((message: AgentManagerOutMessage) => void posted.push(message)) + + instance.postRunMessage({ type: "agentManager.runStatus", worktreeId: "prj-a:local", state: "running" }) + instance.postRunMessage({ type: "agentManager.runStatus", worktreeId: "wt-a1", state: "running" }) + instance.postRunMessage({ type: "agentManager.runStatus", worktreeId: "local", state: "running" }) + + expect(posted).toEqual([ + { type: "agentManager.runStatus", worktreeId: "local", state: "running", projectId: "prj-a" }, + { type: "agentManager.runStatus", worktreeId: "wt-a1", state: "running", projectId: "prj-a" }, + { type: "agentManager.runStatus", worktreeId: "local", state: "running" }, + ]) + }) + + it("passes non-runStatus messages through untouched", () => { + const instance = provider([]) + const posted: AgentManagerOutMessage[] = [] + instance.postToWebview = mock((message: AgentManagerOutMessage) => void posted.push(message)) + + instance.postRunMessage({ type: "agentManager.localStats", stats: undefined } as never) + expect(posted).toEqual([{ type: "agentManager.localStats", stats: undefined }]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts new file mode 100644 index 0000000000..146a738be6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-selection-webview.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test" +import { applyProjectSelection } from "../../webview-ui/agent-manager/project/selection" + +function deps(active: string) { + const calls: string[] = [] + return { + calls, + value: { + active: (projectId: string) => projectId === active, + managed: () => [], + local: (projectId: string) => calls.push(`local:${projectId}`), + worktree: (projectId: string, worktreeId: string) => calls.push(`worktree:${projectId}:${worktreeId}`), + session: (sessionId: string) => calls.push(`session:${sessionId}`), + openTab: (sessionId: string) => calls.push(`openTab:${sessionId}`), + managedSession: (worktreeId: string, sessionId: string) => calls.push(`managed:${worktreeId}:${sessionId}`), + }, + } +} + +describe("applyProjectSelection", () => { + it("ignores delayed local and worktree acknowledgements from another project", () => { + const result = deps("prj-b") + + expect( + applyProjectSelection( + { type: "agentManager.selectionActivated", target: { projectId: "prj-a", kind: "local" } } as never, + result.value, + ), + ).toBe(true) + expect( + applyProjectSelection( + { + type: "agentManager.selectionActivated", + target: { projectId: "prj-a", kind: "worktree", worktreeId: "wt-a" }, + } as never, + result.value, + ), + ).toBe(true) + + expect(result.calls).toEqual([]) + }) + + it("applies acknowledgements for the catalog-active project", () => { + const result = deps("prj-a") + applyProjectSelection( + { type: "agentManager.selectionActivated", target: { projectId: "prj-a", kind: "local" } } as never, + result.value, + ) + applyProjectSelection( + { + type: "agentManager.selectionActivated", + target: { projectId: "prj-a", kind: "worktree", worktreeId: "wt-a" }, + } as never, + result.value, + ) + + expect(result.calls).toEqual(["local:prj-a", "worktree:prj-a:wt-a"]) + }) + + it("applies a worktree ack optimistically even before the state push arrives", () => { + // Regression: reactivation pushes state asynchronously, so the ack can + // arrive first. The catalog push is synchronous, so the project is already + // active and the ack must not be dropped. + const result = deps("prj-a") + applyProjectSelection( + { + type: "agentManager.selectionActivated", + target: { projectId: "prj-a", kind: "worktree", worktreeId: "wt-a" }, + } as never, + result.value, + ) + + expect(result.calls).toEqual(["worktree:prj-a:wt-a"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts new file mode 100644 index 0000000000..31d064541f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "bun:test" +import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages" +import type { ProjectContext, ProjectInitResult } from "../../src/agent-manager/project/context" +import { ProjectContexts } from "../../src/agent-manager/project/contexts" +import { projectIdFor } from "../../src/agent-manager/project/paths" +import type { AgentManagerInMessage } from "../../src/agent-manager/types" +import type { StoredProject } from "../../src/agent-manager/project/registry" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +const WORKSPACE = "/repo/main" +const PINNED = projectIdFor(WORKSPACE) + +function stored(id: string, trusted = true): StoredProject { + return { id, root: `/repo/${id}`, order: 1, trusted, addedAt: new Date().toISOString() } +} + +function fakeState(persisted?: { current?: unknown }) { + const store = persisted ?? {} + return { + getWorktree: (id: string) => (id === "wt1" ? { path: "/repo/prj-extra/wt1" } : undefined), + getSession: (id: string) => (id === "sess1" ? {} : undefined), + getActiveTarget: () => store.current, + setActiveTarget: (target: unknown) => { + store.current = target + }, + } as unknown as WorktreeStateManager +} + +function setup( + opts: { + enabled?: boolean + workspace?: string + ready?: (ctx: ProjectContext) => Promise + state?: () => WorktreeStateManager + } = {}, +) { + const extra = "prj-extra" + const projects = [stored(extra, true)] + const registry = { + list: () => projects, + get: (id: string) => projects.find((p) => p.id === id), + } + const contexts = new ProjectContexts({ + workspaceRoot: () => opts.workspace ?? WORKSPACE, + registry, + trusted: (id) => registry.get(id)?.trusted === true, + enabled: () => opts.enabled ?? true, + deps: { + log: () => {}, + exists: () => true, + state: opts.state ?? (() => fakeState()), + }, + }) + const calls = { + activate: [] as string[], + expand: [] as string[], + push: 0, + error: [] as string[], + selected: [] as string[], + ready: [] as string[], + } + const readyImpl = + opts.ready ?? + (async (ctx: ProjectContext) => { + calls.ready.push(ctx.id) + return ctx.ensureReady(async () => { + ctx.stateManager() + return { ok: true, refsFixed: 0 } + }) + }) + const deps: ProjectMessageDeps = { + registry: registry as never, + contexts, + enabled: () => opts.enabled ?? true, + pickFolder: async () => undefined, + activate: (ctx) => calls.activate.push(ctx.id), + expand: (ctx) => calls.expand.push(ctx.id), + push: () => calls.push++, + error: (message) => calls.error.push(message), + ready: readyImpl, + selected: (target) => calls.selected.push(target.kind === "local" ? target.projectId : target.kind), + log: () => {}, + } + return { contexts, deps, calls, extra } +} + +function activateMsg(projectId: string, extra: Record = {}): AgentManagerInMessage { + return { + type: "agentManager.activateSelection", + target: { projectId, kind: "local", ...extra }, + } as unknown as AgentManagerInMessage +} + +describe("activateSelection — cross-project selection", () => { + it("acknowledges a ready cross-project local selection without re-running completed initialization", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + expect(ctx.lifecycle).toBe("ready") + + calls.ready = [] + await handleProjectMessage(activateMsg(extra), deps) + + expect(contexts.active()?.id).toBe(extra) + expect(calls.activate).toEqual([extra]) + expect(calls.ready).toEqual([extra]) + expect(calls.selected).toEqual([extra]) + expect(calls.error).toEqual([]) + }) + + it("waits for readiness on a cold (never-initialized) selection", async () => { + const { contexts, deps, calls, extra } = setup() + expect(contexts.get(extra)).toBeUndefined() + + await handleProjectMessage(activateMsg(extra), deps) + + expect(contexts.get(extra)?.lifecycle).toBe("ready") + expect(contexts.active()?.id).toBe(extra) + expect(calls.ready).toEqual([extra]) + expect(calls.activate).toEqual([extra]) + expect(calls.selected).toEqual([extra]) + }) + + it("falls back to local without an error toast when the target is gone", async () => { + const { contexts, deps, calls, extra } = setup() + contexts.activate(PINNED) + expect(contexts.active()?.id).toBe(PINNED) + + await handleProjectMessage(activateMsg(extra, { kind: "worktree", worktreeId: "missing" }), deps) + + // A stale target is not actionable, so the selection lands on the + // project's local context instead of surfacing an error. + expect(contexts.active()?.id).toBe(extra) + expect(calls.activate).toEqual([extra]) + expect(calls.selected).toEqual([extra]) + expect(calls.error).toEqual([]) + }) + + it("does not re-run initialization for an already-ready context", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + expect(ctx.lifecycle).toBe("ready") + + const readyCalls: string[] = [] + deps.ready = async (c) => { + readyCalls.push(c.id) + return { ok: true, refsFixed: 0, current: true } + } + + await handleProjectMessage(activateMsg(extra), deps) + await handleProjectMessage(activateMsg(extra), deps) + + expect(readyCalls).toEqual([extra, extra]) + const initField = (ctx as unknown as { init?: unknown }).init + expect(initField).toBeUndefined() + }) + + it("does not reset project services when selecting within the active project", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + contexts.activate(extra) + + await handleProjectMessage(activateMsg(extra, { kind: "worktree", worktreeId: "wt1" }), deps) + + expect(contexts.active()?.id).toBe(extra) + expect(calls.activate).toEqual([]) + expect(calls.selected).toEqual(["worktree"]) + expect(calls.error).toEqual([]) + }) + + it("restores the persisted target when the selection asks for it", async () => { + const persisted = { current: undefined as unknown } + const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) }) + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + persisted.current = { projectId: extra, kind: "worktree", worktreeId: "wt1" } + + await handleProjectMessage( + { type: "agentManager.activateSelection", target: { projectId: extra, kind: "local" }, restore: true } as never, + deps, + ) + + expect(calls.selected).toEqual(["worktree"]) + expect(calls.error).toEqual([]) + }) + + it("falls back to the requested target when the persisted one is gone", async () => { + const persisted = { current: { projectId: "prj-extra", kind: "worktree", worktreeId: "missing" } as unknown } + const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) }) + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + + await handleProjectMessage( + { type: "agentManager.activateSelection", target: { projectId: extra, kind: "local" }, restore: true } as never, + deps, + ) + + expect(calls.selected).toEqual([extra]) + expect(calls.error).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts b/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts new file mode 100644 index 0000000000..7bab778417 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it } from "bun:test" +import type { Session } from "@kilocode/sdk/v2/client" +import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context" +import type { Worktree, ManagedSession, WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" +import { + collectProjectSessions, + projectDirectories, + pushProjectSessions, + registerProjectSessions, + unregisterProjectRoutes, + type ProjectSessionListing, +} from "../../src/agent-manager/project/init" +import { ProjectRouteService, type SessionRef, type WorktreeRef } from "../../src/agent-manager/project/route" + +const ROOT = "/repo/main" +const WT_PATH = "/repo/main/.kilo/worktrees/fix" +const OTHER = "/other/project" + +function mkSession(id: string, dir: string): Session { + return { + id, + slug: id, + projectID: "prj-test", + directory: dir, + title: `Session ${id}`, + version: "1", + time: { created: 1000, updated: 2000 }, + } +} + +/** Build a fake WorktreeStateManager with just the queries project-init uses. */ +function fakeState(worktrees: Worktree[], sessions: ManagedSession[] = []): WorktreeStateManager { + const wtMap = new Map(worktrees.map((w) => [w.id, w])) + return { + getWorktrees: () => worktrees, + getWorktree: (id: string) => wtMap.get(id), + getSessions: () => sessions, + flush: async () => {}, + } as unknown as WorktreeStateManager +} + +function makeContext(root: string, state: WorktreeStateManager, id = "prj-test"): ProjectContext { + const deps: ProjectContextDeps = { + log: () => {}, + state: () => state, + } + const ctx = new ProjectContext(id, root, false, deps) + // Instantiate the lazy state so peekState() returns the fake. + ctx.stateManager() + return ctx +} + +/** A recording ProjectSessionListing: serves sessions by directory and records registrations. */ +function recordingListing(byDir: Record Session[] | Promise)>): { + listing: ProjectSessionListing + calls: string[] + registered: Map +} { + const calls: string[] = [] + const registered = new Map() + const listing: ProjectSessionListing = { + listSessions: async (dir) => { + calls.push(dir) + const value = byDir[dir] + const resolved = typeof value === "function" ? await value() : (value ?? []) + return resolved + }, + setSessionDirectory: (id, dir) => { + registered.set(id, dir) + }, + } + return { listing, calls, registered } +} + +/** + * A recording listing that also forwards route registrations to a real + * ProjectRouteService, so tests can assert exact-route behavior end-to-end + * through the project-init functions. Includes trackSession so it satisfies + * registerProjectSessions' full parameter shape. + */ +function routeListing( + routes: ProjectRouteService, + byDir: Record Session[] | Promise)>, + projectId: string, + generation: number, +): { + listing: ProjectSessionListing & { trackSession(id: string): void } + calls: string[] + registered: Map + tracked: string[] +} { + const calls: string[] = [] + const registered = new Map() + const tracked: string[] = [] + const listing: ProjectSessionListing & { trackSession(id: string): void } = { + listSessions: async (dir) => { + calls.push(dir) + const value = byDir[dir] + const resolved = typeof value === "function" ? await value() : (value ?? []) + return resolved + }, + setSessionDirectory: (id, dir) => { + registered.set(id, dir) + }, + trackSession: (id) => tracked.push(id), + registerProjectRoute: (ref, root, gen) => routes.registerProject(ref.projectId, root, gen), + unregisterProjectRoute: (id) => routes.unregisterProject(id), + registerWorktreeRoute: (ref, dir, gen) => routes.registerWorktree(ref, dir, gen), + registerSessionRoute: (ref, dir, gen) => routes.registerSession(ref, dir, gen), + unregisterSessionRoute: (ref) => routes.unregisterSession(ref), + } + void projectId + void generation + return { listing, calls, registered, tracked } +} + +describe("Agent Manager per-project session discovery", () => { + it("projectDirectories is exactly ctx.root plus this context's worktree paths", () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + expect(projectDirectories(ctx).sort()).toEqual([ROOT, WT_PATH].sort()) + }) + + it("projectDirectories omits worktrees with empty paths and dedupes ctx.root", () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: ROOT, parentBranch: "main", createdAt: "" } + const empty: Worktree = { id: "wt-2", branch: "fix2", path: "", parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt, empty])) + expect(projectDirectories(ctx)).toEqual([ROOT]) + }) + + it("lists root and worktree sessions and registers each with its exact directory", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const rootSession = mkSession("ses-root", ROOT) + const wtSession = mkSession("ses-wt", WT_PATH) + const { listing, calls, registered } = recordingListing({ + [ROOT]: [rootSession], + [WT_PATH]: [wtSession], + }) + + const out = await collectProjectSessions(ctx, listing) + + expect(out.map((s) => s.id).sort()).toEqual(["ses-root", "ses-wt"].sort()) + expect(calls.sort()).toEqual([ROOT, WT_PATH].sort()) + expect(registered.get("ses-root")).toBe(ROOT) + expect(registered.get("ses-wt")).toBe(WT_PATH) + // sessionToWebview round-trip + expect(out.find((s) => s.id === "ses-root")?.title).toBe("Session ses-root") + }) + + it("does not list or include sessions from unrelated-project directories", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const foreign = mkSession("ses-foreign", OTHER) + const rootSession = mkSession("ses-root", ROOT) + // The foreign directory is present in the listing map but must never be queried, + // because ctx only owns ROOT + its own worktree paths. + const { listing, calls } = recordingListing({ + [ROOT]: [rootSession], + [WT_PATH]: [], + [OTHER]: [foreign], + }) + + const out = await collectProjectSessions(ctx, listing) + + expect(calls).not.toContain(OTHER) + expect(out.map((s) => s.id)).toEqual(["ses-root"]) + }) + + it("retains valid sessions when one directory listing fails", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const wtSession = mkSession("ses-wt", WT_PATH) + const { listing, registered } = recordingListing({ + [ROOT]: () => { + throw new Error("backend not connected") + }, + [WT_PATH]: [wtSession], + }) + + const out = await collectProjectSessions(ctx, listing) + + expect(out.map((s) => s.id)).toEqual(["ses-wt"]) + expect(registered.get("ses-wt")).toBe(WT_PATH) + }) + + it("retains valid sessions when listSessions rejects (promise) for one directory", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const wtSession = mkSession("ses-wt", WT_PATH) + const calls: string[] = [] + const registered = new Map() + const listing: ProjectSessionListing = { + listSessions: async (dir) => { + calls.push(dir) + if (dir === ROOT) throw new Error("boom") + return [wtSession] + }, + setSessionDirectory: (id, dir) => registered.set(id, dir), + } + + const out = await collectProjectSessions(ctx, listing) + + expect(out.map((s) => s.id)).toEqual(["ses-wt"]) + expect(registered.get("ses-wt")).toBe(WT_PATH) + }) + + it("returns [] when the SessionProvider has no listSessions", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const listing: ProjectSessionListing = { setSessionDirectory: () => {} } + + const out = await collectProjectSessions(ctx, listing) + + expect(out).toEqual([]) + }) + + it("dedupes a session reported under two directories, keeping the first directory", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const shared = mkSession("ses-shared", ROOT) + const { listing, registered } = recordingListing({ + [ROOT]: [shared], + [WT_PATH]: [shared], + }) + + const out = await collectProjectSessions(ctx, listing) + + expect(out.map((s) => s.id)).toEqual(["ses-shared"]) + // ctx.root is listed first (insertion order of the Set), so it wins. + expect(registered.get("ses-shared")).toBe(ROOT) + }) + + it("registerProjectSessions preserves managed placement (worktree session -> worktree path, local -> root)", () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const managed: ManagedSession[] = [ + { id: "ses-wt", worktreeId: "wt-1", createdAt: "" }, + { id: "ses-local", worktreeId: null, createdAt: "" }, + ] + const ctx = makeContext(ROOT, fakeState([wt], managed)) + const registered = new Map() + const tracked: string[] = [] + registerProjectSessions(ctx, { + setSessionDirectory: (id, dir) => registered.set(id, dir), + trackSession: (id) => tracked.push(id), + }) + + expect(registered.get("ses-wt")).toBe(WT_PATH) + expect(registered.get("ses-local")).toBe(ROOT) + expect(tracked.sort()).toEqual(["ses-local", "ses-wt"].sort()) + }) + + it("registerProjectSessions falls back to ctx.root when a managed worktree is gone", () => { + const managed: ManagedSession[] = [{ id: "ses-orphan", worktreeId: "wt-gone", createdAt: "" }] + const ctx = makeContext(ROOT, fakeState([], managed)) + const registered = new Map() + registerProjectSessions(ctx, { + setSessionDirectory: (id, dir) => registered.set(id, dir), + trackSession: () => {}, + }) + + expect(registered.get("ses-orphan")).toBe(ROOT) + }) + + it("pushProjectSessions posts one projectSessions message with the collected ids", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const { listing } = recordingListing({ + [ROOT]: [mkSession("ses-root", ROOT)], + [WT_PATH]: [mkSession("ses-wt", WT_PATH)], + }) + const posted: Array<{ type: string; projectId: string; sessions: { id: string }[] }> = [] + + await pushProjectSessions(ctx, listing, (msg) => posted.push(msg)) + + expect(posted).toHaveLength(1) + expect(posted[0]!.type).toBe("agentManager.projectSessions") + expect(posted[0]!.projectId).toBe("prj-test") + expect(posted[0]!.sessions.map((s) => s.id).sort()).toEqual(["ses-root", "ses-wt"].sort()) + }) + + it("pushProjectSessions posts nothing when the context is disposed", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const { listing } = recordingListing({ [ROOT]: [mkSession("ses-root", ROOT)] }) + const posted: unknown[] = [] + await ctx.dispose() + await pushProjectSessions(ctx, listing, (msg) => posted.push(msg)) + expect(posted).toHaveLength(0) + }) + + it("pushProjectSessions re-posts the cached list while the previous listing is fresh", async () => { + const ctx = makeContext(ROOT, fakeState([])) + const { listing, calls } = recordingListing({ [ROOT]: [mkSession("ses-root", ROOT)] }) + const posted: Array<{ sessions: Array<{ id: string }> }> = [] + + await pushProjectSessions(ctx, listing, (msg) => posted.push(msg)) + expect(posted).toHaveLength(1) + expect(calls).toHaveLength(1) + + // Fresh skip: no new backend listing, but the cached list is re-posted so + // a webview that mounted after the first push still learns the sessions. + await pushProjectSessions(ctx, listing, (msg) => posted.push(msg)) + expect(posted).toHaveLength(2) + expect(posted[1]!.sessions.map((s) => s.id)).toEqual(["ses-root"]) + expect(calls).toHaveLength(1) + }) + + it("upsertSession makes a new session visible without a backend listing", async () => { + const ctx = makeContext(ROOT, fakeState([])) + const { listing } = recordingListing({ [ROOT]: [mkSession("ses-root", ROOT)] }) + const posted: Array<{ sessions: Array<{ id: string }> }> = [] + + await pushProjectSessions(ctx, listing, (msg) => posted.push(msg)) + ctx.upsertSession({ + id: "ses-new", + parentID: null, + title: "new", + createdAt: "", + updatedAt: "", + revert: null, + summary: null, + worktreeId: null, + }) + expect(ctx.hasLiveSession("ses-new")).toBe(true) + + // The next push (still fresh) includes the upserted session from the cache. + ctx.invalidateSessions() + const { listing: listing2, calls } = recordingListing({ + [ROOT]: [mkSession("ses-root", ROOT), mkSession("ses-new", ROOT)], + }) + await pushProjectSessions(ctx, listing2, (msg) => posted.push(msg)) + expect( + posted + .at(-1)! + .sessions.map((s) => s.id) + .sort(), + ).toEqual(["ses-new", "ses-root"]) + expect(calls).toHaveLength(1) + }) +}) + +describe("Agent Manager route registration during project-init", () => { + it("registerProjectSessions registers the project root, worktrees, and managed session routes", () => { + const routes = new ProjectRouteService() + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const managed: ManagedSession[] = [ + { id: "ses-wt", worktreeId: "wt-1", createdAt: "" }, + { id: "ses-local", worktreeId: null, createdAt: "" }, + ] + const ctx = makeContext(ROOT, fakeState([wt], managed)) + const { listing } = routeListing(routes, {}, ctx.id, ctx.generation) + + registerProjectSessions(ctx, listing) + + // Project root route + expect(routes.projectRoot({ projectId: ctx.id })).toBe(ROOT) + // Worktree route + expect(routes.worktreeDirectory({ projectId: ctx.id, worktreeId: "wt-1" })).toBe(WT_PATH) + // Session routes resolve exactly + const wtRef: SessionRef = { projectId: ctx.id, sessionId: "ses-wt" } + const localRef: SessionRef = { projectId: ctx.id, sessionId: "ses-local" } + expect(routes.sessionDirectory(wtRef)).toBe(WT_PATH) + expect(routes.sessionDirectory(localRef)).toBe(ROOT) + }) + + it("collectProjectSessions registers routes for every live session and worktree", async () => { + const routes = new ProjectRouteService() + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const rootSession = mkSession("ses-root", ROOT) + const wtSession = mkSession("ses-wt", WT_PATH) + const { listing } = routeListing(routes, { [ROOT]: [rootSession], [WT_PATH]: [wtSession] }, ctx.id, ctx.generation) + + await collectProjectSessions(ctx, listing) + + expect(routes.projectRoot({ projectId: ctx.id })).toBe(ROOT) + const wtDirRef: WorktreeRef = { projectId: ctx.id, worktreeId: "wt-1" } + expect(routes.worktreeDirectory(wtDirRef)).toBe(WT_PATH) + expect(routes.sessionDirectory({ projectId: ctx.id, sessionId: "ses-root" })).toBe(ROOT) + expect(routes.sessionDirectory({ projectId: ctx.id, sessionId: "ses-wt" })).toBe(WT_PATH) + }) + + it("same raw session id in two projects is ambiguous after both register", () => { + const routes = new ProjectRouteService() + const ctxA = makeContext("/repo/a", fakeState([]), "prj-a") + const ctxB = makeContext("/repo/b", fakeState([]), "prj-b") + const listingA = routeListing(routes, {}, ctxA.id, ctxA.generation).listing + const listingB = routeListing(routes, {}, ctxB.id, ctxB.generation).listing + + // Register each project root first (as registerProjectRoutes would), + // then register the same raw session id in both projects. + listingA.registerProjectRoute!({ projectId: ctxA.id }, "/repo/a", ctxA.generation) + listingB.registerProjectRoute!({ projectId: ctxB.id }, "/repo/b", ctxB.generation) + listingA.registerSessionRoute!({ projectId: ctxA.id, sessionId: "same" }, "/repo/a", ctxA.generation) + listingB.registerSessionRoute!({ projectId: ctxB.id, sessionId: "same" }, "/repo/b", ctxB.generation) + + expect(routes.isSessionAmbiguous("same")).toBe(true) + expect(routes.trySessionDirectory("same")).toBeUndefined() + // Project-qualified refs still resolve exactly. + expect(routes.trySessionDirectoryFor({ projectId: ctxA.id, sessionId: "same" })).toBe("/repo/a") + expect(routes.trySessionDirectoryFor({ projectId: ctxB.id, sessionId: "same" })).toBe("/repo/b") + }) + + it("unregisterProjectRoutes clears all routes for a project", () => { + const routes = new ProjectRouteService() + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const managed: ManagedSession[] = [{ id: "ses-wt", worktreeId: "wt-1", createdAt: "" }] + const ctx = makeContext(ROOT, fakeState([wt], managed)) + const { listing } = routeListing(routes, {}, ctx.id, ctx.generation) + + registerProjectSessions(ctx, listing) + expect(routes.hasSession({ projectId: ctx.id, sessionId: "ses-wt" })).toBe(true) + + unregisterProjectRoutes(ctx, listing) + expect(routes.hasSession({ projectId: ctx.id, sessionId: "ses-wt" })).toBe(false) + expect(routes.trySessionDirectory("ses-wt")).toBeUndefined() + }) + + it("registerProjectSessions is a no-op when the listing has no route methods", () => { + const routes = new ProjectRouteService() + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const managed: ManagedSession[] = [{ id: "ses-wt", worktreeId: "wt-1", createdAt: "" }] + const ctx = makeContext(ROOT, fakeState([wt], managed)) + // Plain listing without route methods — must not throw. + const registered = new Map() + const listing = { + setSessionDirectory: (id: string, dir: string) => registered.set(id, dir), + trackSession: () => {}, + } + registerProjectSessions(ctx, listing) + expect(registered.get("ses-wt")).toBe(WT_PATH) + // No routes were registered. + expect(routes.trySessionDirectory("ses-wt")).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts b/packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts new file mode 100644 index 0000000000..ecc57fe54f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-state-router.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test" +import { createProjectStateRouter } from "../../webview-ui/agent-manager/project/state" +import type { AgentManagerStateMessage, AgentProjectSnapshot } from "../../src/types/messages" + +const state = (projectId: string) => ({ type: "agentManager.state", projectId }) as AgentManagerStateMessage +const project = (id: string, active = false) => ({ id, label: id, root: `/repo/${id}`, active }) as AgentProjectSnapshot + +function setup(catalog: AgentProjectSnapshot[] = []) { + const calls: { applied: string[]; pruned: string[][] } = { applied: [], pruned: [] } + let list = catalog + const router = createProjectStateRouter({ + catalog: () => list, + apply: (s) => calls.applied.push(s.projectId ?? ""), + pruneLive: (ids) => calls.pruned.push([...ids]), + }) + return { router, calls, set: (projects: AgentProjectSnapshot[]) => (list = projects) } +} + +describe("createProjectStateRouter", () => { + it("applies state directly when the catalog is empty (legacy mode)", () => { + const { router, calls } = setup() + expect(router.routeState(state("prj-a"))).toBe("applied") + expect(calls.applied).toEqual(["prj-a"]) + }) + + it("defers state that arrives before its catalog activation and flushes it", () => { + const { router, calls, set } = setup([project("prj-a", true), project("prj-b")]) + + expect(router.routeState(state("prj-b"))).toBe("deferred") + expect(calls.applied).toEqual([]) + + set([project("prj-a"), project("prj-b", true)]) + router.routeCatalog([project("prj-a"), project("prj-b", true)]) + expect(calls.applied).toEqual(["prj-b"]) + }) + + it("applies state immediately when its project is already catalog-active", () => { + const { router, calls } = setup([project("prj-a", true), project("prj-b")]) + expect(router.routeState(state("prj-a"))).toBe("applied") + expect(calls.applied).toEqual(["prj-a"]) + }) + + it("keeps the newest deferred state per project", () => { + const { router, calls, set } = setup([project("prj-a", true)]) + router.routeState({ ...state("prj-b"), worktrees: [{ id: "1" }] } as never) + router.routeState({ ...state("prj-b"), worktrees: [{ id: "1" }, { id: "2" }] } as never) + set([project("prj-b", true)]) + router.routeCatalog([project("prj-b", true)]) + expect(calls.applied).toHaveLength(1) + }) + + it("drops deferred state for projects removed from the catalog", () => { + const { router, calls, set } = setup([project("prj-a", true), project("prj-b")]) + router.routeState(state("prj-b")) + set([project("prj-a", true)]) + router.routeCatalog([project("prj-a", true)]) + expect(calls.applied).toEqual([]) + expect(calls.pruned.at(-1)).toEqual(["prj-a"]) + }) + + it("does not flush deferred state for a project that is not the active one", () => { + const { router, calls, set } = setup([project("prj-a", true), project("prj-b")]) + router.routeState(state("prj-b")) + set([project("prj-c", true)]) + router.routeCatalog([project("prj-a"), project("prj-b"), project("prj-c", true)]) + expect(calls.applied).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/config-bindings.test.ts b/packages/kilo-vscode/tests/unit/config-bindings.test.ts new file mode 100644 index 0000000000..fbb1327aaf --- /dev/null +++ b/packages/kilo-vscode/tests/unit/config-bindings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test" +import { ConfigBindings, type ConfigProject } from "../../src/kilo-provider/config-bindings" + +const { KiloProvider } = await import("../../src/KiloProvider") + +const target = { + scope: "project" as const, + path: "/repo/.kilo/kilo.jsonc", + revision: "revision", + exists: false, + writable: true, + raw: {}, +} + +describe("ConfigBindings", () => { + it("keeps the read-time directory and target immutable", () => { + const bindings = new ConfigBindings() + const binding = bindings.create({ + connection: 1, + scope: "project", + directory: "/repo/a", + target, + project: { id: "a", root: "/repo/a", generation: 2, pinned: false }, + }) + + expect(bindings.get(binding.id, 1, () => true)).toMatchObject({ + directory: "/repo/a", + target: { path: target.path, revision: target.revision }, + }) + }) + + it("expires on reconnect, trust revocation, removal, or successful save", () => { + const bindings = new ConfigBindings() + const project: ConfigProject = { id: "a", root: "/repo/a", generation: 2, pinned: false } + const binding = bindings.create({ connection: 1, scope: "project", directory: project.root, target, project }) + + expect(bindings.get(binding.id, 2, () => true)).toBeUndefined() + expect(bindings.get(binding.id, 1, () => false)).toBeUndefined() + expect(bindings.get(binding.id, 1, () => true)).toBeDefined() + bindings.consume(binding.id) + expect(bindings.get(binding.id, 1, () => true)).toBeUndefined() + }) + + it("expires retained-panel bindings when the selected project changes", () => { + const provider = new KiloProvider({} as never, {} as never, undefined, { projectDirectory: "/repo/a" }) + const internal = provider as unknown as { configBindings: ConfigBindings; connectionGeneration: number } + const binding = internal.configBindings.create({ + connection: internal.connectionGeneration, + scope: "global", + directory: "/repo/a", + target: { ...target, scope: "global" }, + }) + + provider.setProjectDirectory("/repo/b") + + expect(internal.configBindings.get(binding.id, internal.connectionGeneration, () => true)).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts index eca588a5d1..80bd762552 100644 --- a/packages/kilo-vscode/tests/unit/config-scope.test.ts +++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test" import { splitConfigByScope } from "../../webview-ui/src/utils/config-scope" describe("splitConfigByScope", () => { - it("writes indexing enablement to project config only", () => { + it("keeps indexing configuration out of project config", () => { const split = splitConfigByScope({ indexing: { enabled: true, @@ -10,8 +10,8 @@ describe("splitConfigByScope", () => { }, }) - expect(split.global).toEqual({ indexing: { provider: "ollama" } }) - expect(split.project).toEqual({ indexing: { enabled: true } }) + expect(split.global).toEqual({ indexing: { enabled: true, provider: "ollama" } }) + expect(split.project).toEqual({}) }) it("writes indexing provider settings to global config", () => { @@ -25,14 +25,6 @@ describe("splitConfigByScope", () => { expect(split.project).toEqual({}) }) - it("can write indexing enablement to global config through a global draft", () => { - const split = splitConfigByScope({ username: "marius" }) - const draft = { indexing: { enabled: true } } - - expect({ ...split.global, ...draft }).toEqual({ username: "marius", indexing: { enabled: true } }) - expect(split.project).toEqual({}) - }) - it("writes the speech-to-text model setting to global config", () => { const split = splitConfigByScope({ experimental: { diff --git a/packages/kilo-vscode/tests/unit/diff-scope.test.ts b/packages/kilo-vscode/tests/unit/diff-scope.test.ts new file mode 100644 index 0000000000..9794d4bc6e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-scope.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "bun:test" +import { + composeDiffId, + parseDiffId, + isDiffScope, + normalizeScope, + scopeToSourceId, + DEFAULT_DIFF_SCOPE, +} from "../../src/agent-manager/diff-scope" + +describe("diff-scope composite ids", () => { + it("round-trips context and scope", () => { + expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" }) + expect(parseDiffId(composeDiffId("wt_abc", "staged"))).toEqual({ ctx: "wt_abc", scope: "staged" }) + expect(parseDiffId(composeDiffId("wt_abc", "unstaged"))).toEqual({ ctx: "wt_abc", scope: "unstaged" }) + expect(parseDiffId(composeDiffId("wt_abc", "session"))).toEqual({ ctx: "wt_abc", scope: "session" }) + }) + + it("embeds the active session id in the session scope", () => { + const id = composeDiffId("wt_abc", "session", "ses_xyz") + expect(id).toBe("wt_abc#session:ses_xyz") + expect(parseDiffId(id)).toEqual({ ctx: "wt_abc", scope: "session", sessionId: "ses_xyz" }) + expect(parseDiffId(composeDiffId("local", "session", "ses_xyz"))).toEqual({ + ctx: "local", + scope: "session", + sessionId: "ses_xyz", + }) + }) + + it("parses context ids containing no separator as default branch scope", () => { + expect(parseDiffId("wt_abc")).toEqual({ ctx: "wt_abc", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("treats an unknown trailing segment as part of the context, not a scope", () => { + // A context id that happens to contain '#' but not a valid scope keeps the + // full id as context and falls back to branch. + expect(parseDiffId("wt_a#bogus")).toEqual({ ctx: "wt_a#bogus", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("isDiffScope guards the closed enum", () => { + expect(isDiffScope("branch")).toBe(true) + expect(isDiffScope("staged")).toBe(true) + expect(isDiffScope("unstaged")).toBe(true) + expect(isDiffScope("session")).toBe(true) + expect(isDiffScope("session:ses_xyz")).toBe(false) + expect(isDiffScope("turn")).toBe(false) + expect(isDiffScope("")).toBe(false) + }) + + it("normalizeScope falls back to branch for unknown input", () => { + expect(normalizeScope("staged")).toBe("staged") + expect(normalizeScope("nope")).toBe("branch") + expect(normalizeScope(undefined)).toBe("branch") + expect(normalizeScope(42)).toBe("branch") + }) + + it("maps scopes to catalog source ids", () => { + expect(scopeToSourceId("branch", "wt_abc")).toBe("workspace") + expect(scopeToSourceId("staged", "wt_abc")).toBe("staged") + expect(scopeToSourceId("unstaged", "wt_abc")).toBe("unstaged") + expect(scopeToSourceId("session", "wt_abc", "ses_xyz")).toBe("session:ses_xyz") + expect(scopeToSourceId("session", "local", "ses_xyz")).toBe("session:ses_xyz") + expect(scopeToSourceId("branch", "local")).toBe("workspace") + }) + + it("falls back to the context id for a session scope without a session id", () => { + expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 3e8e4d3067..160823e513 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -127,7 +127,7 @@ describe("Extension — package.json command sync", () => { expect(terminal).toMatchObject({ key: "ctrl+/", mac: "cmd+/", - when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused", }) expect(create).toMatchObject({ key: "ctrl+shift+t", diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index 0afb8b9254..c62663414f 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -83,6 +83,54 @@ describe("GitOps", () => { }) describe("GitStatsPoller", () => { + it("keeps mutual exclusion when a stale fetch finishes after a restart", async () => { + let calls = 0 + let running = 0 + let max = 0 + let gate: (() => void) | undefined + const gated = new Promise((resolve) => { + gate = resolve + }) + const localDiff = async () => { + calls += 1 + running += 1 + max = Math.max(max, running) + if (calls === 1) await gated + running -= 1 + return [{ ...diff(1, 0)[0], stamp: `s${calls}` }] + } + const poller = new GitStatsPoller({ + getWorktrees: () => [], + getWorkspaceRoot: () => "/tmp", + localDiff, + onStats: () => undefined, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 5, + git: gitOps(async (args) => { + if (args[0] === "rev-parse" && args[2] === "HEAD") return "main" + if (args[0] === "symbolic-ref") return "origin/main" + if (args[0] === "rev-list") return "0 0" + return "" + }), + }) + + poller.setEnabled(true) + await waitFor(() => calls === 1) + // Restart while the first fetch is in flight, then let it finish. The + // stale finally must not clear the busy flag owned by the new poll. + poller.stop() + poller.setEnabled(true) + await waitFor(() => calls === 2) + gate?.() + await waitFor(() => calls >= 3) + poller.stop() + + // The gated first fetch and the restarted poll overlapped by + // construction; no further overlap may occur after the restart. + expect(max).toBeLessThanOrEqual(2) + }) + it("does not overlap polling runs", async () => { let running = 0 let max = 0 @@ -279,6 +327,57 @@ describe("GitStatsPoller", () => { expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"]) }) + it("treats symlink-aliased worktree paths as present via realpath canonicalization", async () => { + // git worktree list --porcelain realpath-resolves worktree registration, so + // a worktree registered through a symlink alias (e.g. /tmp -> /private/tmp + // on macOS) is reported under its realpath. The probe must canonicalize the + // session's lexical alias the same way, or a real worktree is marked missing + // and its stats are excluded. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-symlink-")) + const real = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-symlink-real-")) + const alias = path.join(root, "alias") + fs.symlinkSync(real, alias) + + const presence: WorktreePresenceResult[] = [] + const calls: string[] = [] + const emitted: Array> = [] + + const poller = new GitStatsPoller({ + getWorktrees: () => [{ ...worktree("a"), path: alias }], + getWorkspaceRoot: () => root, + localDiff: async (dir) => { + calls.push(dir) + return diff(3, 2) + }, + onStats: (stats) => emitted.push(stats), + onLocalStats: () => undefined, + onWorktreePresence: (result) => presence.push(result), + log: () => undefined, + intervalMs: 5, + git: gitOps(async (args) => { + if (args[0] === "worktree") { + return `worktree ${real}\nbranch refs/heads/branch-a\n` + } + if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main" + if (args[0] === "rev-list") return "1" + return "" + }), + }) + + poller.setEnabled(true) + await waitFor(() => presence.length >= 1 && emitted.length >= 1) + poller.stop() + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(real, { recursive: true, force: true }) + + expect(presence[0]).toEqual({ + worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }], + degraded: false, + }) + expect(calls.some((dir) => dir === alias)).toBe(true) + expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"]) + }) + it("preserves local stats when diff fails after initial success", async () => { let diffCalls = 0 const emitted: Array<{ diff --git a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts index 11c8c277e3..1aa3db8111 100644 --- a/packages/kilo-vscode/tests/unit/i18n-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/i18n-keys.test.ts @@ -41,6 +41,7 @@ import { dict as appTr } from "../../webview-ui/src/i18n/tr" import { dict as appNl } from "../../webview-ui/src/i18n/nl" import { dict as appUk } from "../../webview-ui/src/i18n/uk" import { dict as appIt } from "../../webview-ui/src/i18n/it" +import { dict as appFa } from "../../webview-ui/src/i18n/fa" // Layer 2: upstream UI (@opencode-ai/ui re-exported via @kilocode/kilo-ui) import { dict as uiEn } from "../../../ui/src/i18n/en" @@ -114,6 +115,7 @@ import { dict as cliTr } from "../../src/services/cli-backend/i18n/tr" import { dict as cliNl } from "../../src/services/cli-backend/i18n/nl" import { dict as cliUk } from "../../src/services/cli-backend/i18n/uk" import { dict as cliIt } from "../../src/services/cli-backend/i18n/it" +import { dict as cliFa } from "../../src/services/cli-backend/i18n/fa" import { dict as hostEn } from "../../src/services/i18n/en" import { dict as hostZh } from "../../src/services/i18n/zh" @@ -135,6 +137,7 @@ import { dict as hostTr } from "../../src/services/i18n/tr" import { dict as hostNl } from "../../src/services/i18n/nl" import { dict as hostUk } from "../../src/services/i18n/uk" import { dict as hostIt } from "../../src/services/i18n/it" +import { dict as hostFa } from "../../src/services/i18n/fa" // ── Locale maps ───────────────────────────────────────────────────────────── @@ -161,6 +164,7 @@ const appLocales: Record> = { nl: appNl, uk: appUk, it: appIt, + fa: appFa, } const kiloLocales: Record> = { @@ -230,6 +234,7 @@ const cliLocales: Record> = { nl: cliNl, uk: cliUk, it: cliIt, + fa: cliFa, } const hostLocales: Record> = { @@ -253,6 +258,7 @@ const hostLocales: Record> = { nl: hostNl, uk: hostUk, it: hostIt, + fa: hostFa, } // Merge webview dictionaries in the same priority order as language.tsx diff --git a/packages/kilo-vscode/tests/unit/indexing-consent.test.ts b/packages/kilo-vscode/tests/unit/indexing-consent.test.ts new file mode 100644 index 0000000000..bc8e5d504c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/indexing-consent.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { IndexingConsentStore } from "../../src/indexing-consent" + +describe("IndexingConsentStore", () => { + it("isolates consent by canonical project id", async () => { + let value: unknown + const store = new IndexingConsentStore( + { + read: () => value, + write: (next) => { + value = next + }, + }, + async (dir) => (dir.includes("alias") ? "/repo/a" : dir), + ) + const first = await store.project("/repo/a") + const alias = await store.project("/alias/a") + const second = await store.project("/repo/b") + + await store.set(first.id, true) + + expect(alias.id).toBe(first.id) + expect(store.enabled(alias.id)).toBe(true) + expect(store.enabled(second.id)).toBe(false) + }) + + it("serializes concurrent updates without dropping another project", async () => { + let value: unknown + const store = new IndexingConsentStore( + { + read: () => value, + write: async (next) => { + await Promise.resolve() + value = next + }, + }, + async (dir) => dir, + ) + const first = await store.project("/repo/a") + const second = await store.project("/repo/b") + + await Promise.all([store.set(first.id, true), store.set(second.id, true)]) + + expect(store.enabled(first.id)).toBe(true) + expect(store.enabled(second.id)).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 31ed93fd4f..82621a7e46 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import type { Config } from "@kilocode/sdk/v2/client" +import { fetchSnapshot } from "../../src/kilo-provider/config-snapshot" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) const { KiloProvider } = await import("../../src/KiloProvider") @@ -17,17 +18,59 @@ type Internals = { projectUnset?: string[][], ) => Promise fetchAndSendConfig: () => Promise + fetchAndSendConfigUpdated: () => Promise fetchAndSendProviders: () => Promise fetchAndSendAgents: () => Promise fetchAndSendSkills: () => Promise fetchAndSendCommands: () => Promise fetchAndSendNotifications: () => Promise fetchAndSendIndexingStatus: () => Promise + connectionGeneration: number + configBindings: { + create: (input: unknown) => { id: string } + } +} + +function binding(internal: Internals, scope: "global" | "project") { + return internal.configBindings.create({ + connection: internal.connectionGeneration, + scope, + directory: "/repo", + target: { + scope, + path: scope === "global" ? "/config/kilo.jsonc" : "/repo/.kilo/kilo.jsonc", + revision: `${scope}-revision`, + exists: false, + writable: true, + raw: {}, + }, + }) } function createConnection() { let drains = 0 const patches: unknown[] = [] + const snapshot = { + effective: {}, + targets: { + global: { + scope: "global", + path: "/config/kilo.jsonc", + revision: "global-next", + exists: true, + writable: true, + raw: {}, + }, + project: { + scope: "project", + path: "/repo/.kilo/kilo.jsonc", + revision: "project-next", + exists: true, + writable: true, + raw: {}, + }, + }, + } const client = { global: { config: { @@ -38,15 +81,16 @@ function createConnection() { config: { get: async () => ({ data: {} }), update: async () => ({ data: {} }), - overlay: async () => ({ data: { project: {} } }), + overlay: async () => ({ data: { project: {}, targets: snapshot.targets } }), overlayUpdate: async (patch: unknown) => { patches.push(patch) - return { data: {} } + return { data: snapshot } }, }, } return { + client, drains: () => drains, patches: () => patches, service: { @@ -59,6 +103,43 @@ function createConnection() { } describe("KiloProvider indexing refresh", () => { + it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => { + const conn = createConnection() + const settings = () => ({ maxCost: 0, languageCommitMessage: "sync", multiProject: false }) + const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings) + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + const sent: Array> = [] + provider.postMessage = (message) => void sent.push(message as Record) + Object.assign(internal, { connectionState: "connected", commitMessageLanguageSetting: () => "sync" }) + await internal.fetchAndSendConfig() + await internal.fetchAndSendConfigUpdated() + // Save against the binding the latest config load issued, like the webview + // does: each load supersedes older bindings for the same scope+directory. + const issued = (sent[sent.length - 1]!.bindings as { global: { id: string } }).global.id + await internal.handleUpdateConfig({ model: "test/global" }, {}, [], [], issued) + + // bindings carry fresh per-load revision state, so compare the payload + // without them; each message still must carry a bindings object. + const strip = (m: Record) => { + const { bindings, ...rest } = m + expect(bindings).toMatchObject({ global: expect.anything() }) + return rest + } + const payload = { + config: snapshot.config, + globalConfig: snapshot.targets!.global.raw, + projectConfig: snapshot.targets!.project.raw, + settings: snapshot.settings, + features: snapshot.features, + } + expect(sent.map(strip)).toEqual([ + { type: "configLoaded", ...payload }, + { type: "configUpdated", ...payload }, + { type: "configUpdated", ...payload }, + ]) + }) + it("reloadAfterAuthChange fetches config first, then indexing status", async () => { const provider = new KiloProvider({} as never, {} as never) const internal = provider as unknown as Internals @@ -105,7 +186,7 @@ describe("KiloProvider indexing refresh", () => { await internal.handleUpdateConfig({}) - expect(conn.drains()).toBe(1) + expect(conn.drains()).toBe(0) expect(indexing).toBe(0) }) @@ -118,8 +199,9 @@ describe("KiloProvider indexing refresh", () => { internal.fetchAndSendProviders = async () => { calls += 1 } + const global = binding(internal, "global") - await internal.handleUpdateConfig({ hide_prompt_training_models: true }) + await internal.handleUpdateConfig({ hide_prompt_training_models: true }, {}, [], [], global.id) expect(calls).toBe(1) }) @@ -129,29 +211,83 @@ describe("KiloProvider indexing refresh", () => { const provider = new KiloProvider({} as never, conn.service as never) const internal = provider as unknown as Internals internal.connectionState = "connected" + const global = binding(internal, "global") + const project = binding(internal, "project") await internal.handleUpdateConfig( { indexing: { qdrant: { apiKey: undefined } } }, { indexing: { searchMinScore: undefined } }, [["indexing", "qdrant", "apiKey"]], [["indexing", "searchMinScore"]], + global.id, + project.id, ) expect(conn.patches()).toEqual([ expect.objectContaining({ scope: "global", + expected: { path: "/config/kilo.jsonc", revision: "global-revision" }, set: { indexing: { qdrant: { apiKey: undefined } } }, unset: [["indexing", "qdrant", "apiKey"]], }), expect.objectContaining({ scope: "project", + expected: { path: "/repo/.kilo/kilo.jsonc", revision: "project-revision" }, set: { indexing: { searchMinScore: undefined } }, unset: [["indexing", "searchMinScore"]], }), ]) }) - it("fetchAndSendIndexingStatus uses current session directory header", async () => { + it("reports a completed global scope when the project write conflicts", async () => { + const target = (scope: "global" | "project", revision: string) => ({ + scope, + path: scope === "global" ? "/config/kilo.jsonc" : "/repo/.kilo/kilo.jsonc", + revision, + exists: true, + writable: true, + raw: {}, + }) + const snapshot = { + effective: { model: "test/global" }, + targets: { global: target("global", "global-next"), project: target("project", "project-revision") }, + } + const client = { + config: { + overlayUpdate: async (input: { scope: string }) => { + if (input.scope === "project") throw new Error("revision conflict") + return { data: snapshot } + }, + }, + } + const provider = new KiloProvider( + {} as never, + { drainPendingPrompts: async () => {}, getClient: () => client } as never, + ) + const internal = provider as unknown as Internals + const messages: Array> = [] + provider.postMessage = (message) => messages.push(message as Record) + internal.connectionState = "connected" + const global = binding(internal, "global") + const project = binding(internal, "project") + + await internal.handleUpdateConfig( + { model: "test/global" }, + { model: "test/project" }, + [], + [], + global.id, + project.id, + ) + + expect(messages.find((message) => message.type === "configUpdateFailed")).toMatchObject({ + completedScopes: ["global"], + config: snapshot.effective, + bindings: { global: { target: snapshot.targets.global }, project: { target: snapshot.targets.project } }, + }) + }) + + it("fetchAndSendIndexingStatus writes project consent through the dedicated endpoint", async () => { const worktree = "/repo/.kilo/.kilocode/worktrees/feature" const calls: { input: RequestInfo | URL; init?: RequestInit }[] = [] const original = globalThis.fetch @@ -180,6 +316,12 @@ describe("KiloProvider indexing refresh", () => { getClient: () => ({}) as never, getServerConfig: () => ({ baseUrl: "http://127.0.0.1:9999", password: "secret" }), } as never, + { + globalState: { + get: () => undefined, + update: async () => {}, + }, + } as never, ) const internal = provider as unknown as Internals provider.setSessionDirectory("ses_worktree", worktree) @@ -192,6 +334,9 @@ describe("KiloProvider indexing refresh", () => { const auth = Buffer.from("kilo:secret").toString("base64") expect(headers.get("Authorization")).toBe(`Basic ${auth}`) expect(headers.get("x-kilo-directory")).toBe(worktree) + expect(calls[0]?.init?.method).toBe("PUT") + expect(String(calls[0]?.input)).toBe("http://127.0.0.1:9999/indexing/consent") + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ enabled: false }) } finally { globalThis.fetch = original } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts new file mode 100644 index 0000000000..b8053f7abe --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect } from "bun:test" +import { ProjectRouteService } from "../../src/agent-manager/project/route" + +// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) +const { KiloProvider } = await import("../../src/KiloProvider") + +type SessionGetParams = { sessionID: string; directory: string } + +/** + * Minimal connection service mock: exposes a controllable client whose + * session.get records every call so tests can assert which directory was + * queried. Mirrors the shape used by kilo-provider-session-refresh.test.ts. + */ +function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { + const calls: SessionGetParams[] = [] + const client = { + session: { + get: async (p: SessionGetParams) => { + calls.push(p) + if (getImpl) return getImpl(p) + return { + data: { + id: p.sessionID, + slug: p.sessionID, + projectID: "prj-test", + directory: p.directory, + title: "s", + version: "1", + time: { created: 1, updated: 1 }, + }, + } + }, + list: async () => ({ data: [] }), + }, + provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) }, + app: { + agents: async () => ({ data: [] }), + skills: async () => ({ data: [] }), + }, + config: { get: async () => ({ data: {} }) }, + indexing: { status: async () => ({ data: { state: "disabled" } }) }, + kilo: { + notifications: async () => ({ data: [] }), + profile: async () => ({ data: {} }), + }, + } + let current: typeof client | null = client + return { + calls, + connection: { + connect: async () => { + current = client + }, + getClient: () => { + if (!current) throw new Error("Not connected") + return current + }, + getClientAsync: async () => client, + onEventFiltered: () => () => undefined, + onStateChange: () => () => undefined, + onNotificationDismissed: () => () => undefined, + onLanguageChanged: () => () => undefined, + onProfileChanged: () => () => undefined, + onMigrationComplete: () => () => undefined, + onFavoritesChanged: () => () => undefined, + onModelSelectorExpandedChanged: () => () => undefined, + onClearPendingPrompts: () => () => undefined, + registerDirectoryProvider: () => () => undefined, + getServerInfo: () => ({ port: 12345 }), + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), + getConnectionState: () => "connected" as const, + getConnectionError: () => null, + resolveEventSessionId: () => undefined, + recordMessageSessionId: () => undefined, + notifyNotificationDismissed: () => undefined, + } as unknown as ConstructorParameters[1], + } +} + +type ProviderInternals = { + client: unknown + connectionState: "connecting" | "connected" | "disconnected" | "error" + initConnectionPromise: Promise | null + webview: { postMessage: (message: unknown) => Promise } | null + handleSendCommand: ( + command: string, + args: string, + messageID?: string, + sessionID?: string, + draftID?: string, + providerID?: string, + modelID?: string, + agent?: string, + variant?: string, + files?: unknown[], + context?: string, + contextDirectory?: string, + ) => Promise +} + +/** + * Short-circuit initializeConnection: setting initConnectionPromise to a + * resolved promise makes the guarded initializeConnection() return + * immediately, and connectionState="connected" satisfies the guards in + * getSessionInfo. This avoids the full backend init sequence (commands, + * indexing, git status) which the minimal mock does not implement. + */ +function connect(internal: ProviderInternals): void { + internal.connectionState = "connected" + internal.initConnectionPromise = Promise.resolve() +} + +describe("KiloProvider route integration", () => { + it("resolves a unique Local session route to its exact project root", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerSession({ projectId: "a", sessionId: "ses-local" }, "/repo/a", 1) + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + const internal = provider as unknown as ProviderInternals + // Pretend the backend is already connected so getSessionInfo does not + // run the full initialization sequence. + connect(internal) + + await provider.getSessionInfo("ses-local") + + expect(calls).toHaveLength(1) + expect(calls[0]!.directory).toBe("/repo/a") + expect(calls[0]!.sessionID).toBe("ses-local") + }) + + it("resolves a unique worktree session route to its exact worktree directory", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerWorktree({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1) + routes.registerSession({ projectId: "a", sessionId: "ses-wt" }, "/repo/a/.kilo/wt", 1) + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + const internal = provider as unknown as ProviderInternals + connect(internal) + + await provider.getSessionInfo("ses-wt") + + expect(calls).toHaveLength(1) + expect(calls[0]!.directory).toBe("/repo/a/.kilo/wt") + }) + + it("does NOT query the active root for an ambiguous raw session id", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + // Same raw session id registered in two projects — ambiguous. + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + const internal = provider as unknown as ProviderInternals + connect(internal) + + const result = await provider.getSessionInfo("same") + + // Must not hit the backend at all — no active-root fallback. + expect(calls).toHaveLength(0) + expect(result).toBeUndefined() + }) + + it("does NOT silently use a stale sessionDirectories entry for an ambiguous id", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + // Simulate the legacy single-entry map holding only one project's dir. + provider.setSessionDirectory("same", "/repo/a") + const internal = provider as unknown as ProviderInternals + connect(internal) + + await provider.getSessionInfo("same") + + // The route service blocks the query despite sessionDirectories having + // an entry, because the id is ambiguous and no qualifier disambiguates. + expect(calls).toHaveLength(0) + }) + + it("resolves an ambiguous id through a project qualifier to the exact dir", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + projectQualifier: () => ({ projectId: "b" }), + }) + const internal = provider as unknown as ProviderInternals + connect(internal) + + await provider.getSessionInfo("same") + + expect(calls).toHaveLength(1) + expect(calls[0]!.directory).toBe("/repo/b") + }) + + it("falls back to sessionDirectories when no route service is configured (non-Agent-Manager)", async () => { + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => "/active/root", + }) + provider.setSessionDirectory("ses-plain", "/some/dir") + const internal = provider as unknown as ProviderInternals + connect(internal) + + await provider.getSessionInfo("ses-plain") + + expect(calls).toHaveLength(1) + expect(calls[0]!.directory).toBe("/some/dir") + }) + + it("exposes route registration helpers that forward to the route service", () => { + const routes = new ProjectRouteService() + const { connection } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + + provider.registerProjectRoute({ projectId: "a" }, "/repo/a", 1) + provider.registerWorktreeRoute({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1) + provider.registerSessionRoute({ projectId: "a", sessionId: "s" }, "/repo/a/.kilo/wt", 1) + + expect(routes.sessionDirectory({ projectId: "a", sessionId: "s" })).toBe("/repo/a/.kilo/wt") + expect(routes.worktreeDirectory({ projectId: "a", worktreeId: "wt" })).toBe("/repo/a/.kilo/wt") + expect(provider.isSessionRouteAmbiguous("s")).toBe(false) + expect(provider.routeSessionDirectoryFor({ projectId: "a", sessionId: "s" })).toBe("/repo/a/.kilo/wt") + + provider.unregisterSessionRoute({ projectId: "a", sessionId: "s" }) + expect(provider.routeSessionDirectoryFor({ projectId: "a", sessionId: "s" })).toBeUndefined() + }) + + it("refuses to run a share command on an ambiguous raw session id (no active-root fallback)", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerProject("b", "/repo/b", 1) + routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1) + routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1) + const { connection, calls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + const internal = provider as unknown as ProviderInternals + connect(internal) + const posted: unknown[] = [] + internal.webview = { postMessage: async (m) => posted.push(m) } + + // share/unshare are commands routed through handleSendCommand → resolveSession. + await internal.handleSendCommand("share", "", "mid", "same") + + // No backend session.command call should fire — the ambiguous id is + // refused before any directory is resolved. + expect(calls).toHaveLength(0) + const failed = posted.find( + (m) => typeof m === "object" && m !== null && (m as { type?: string }).type === "sendMessageFailed", + ) + expect(failed, "expected a sendMessageFailed message for the ambiguous share command").toBeTruthy() + }) + + it("runs a share command on a unique session route against its exact directory", async () => { + const routes = new ProjectRouteService() + routes.registerProject("a", "/repo/a", 1) + routes.registerWorktree({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1) + routes.registerSession({ projectId: "a", sessionId: "ses-wt" }, "/repo/a/.kilo/wt", 1) + const { connection, calls } = mockConnection() + // Extend the mock client with session.command to record the directory. + const commandCalls: { sessionID: string; directory: string; command: string }[] = [] + ;(connection.getClient() as { session: { command: unknown } }).session.command = async (p: { + sessionID: string + directory: string + command: string + }) => { + commandCalls.push(p) + return { + data: { + id: p.sessionID, + slug: p.sessionID, + directory: p.directory, + title: "s", + version: "1", + time: { created: 1, updated: 1 }, + }, + } + } + const provider = new KiloProvider({} as never, connection, undefined, { + routeService: routes, + rootDirectory: () => "/active/root", + }) + const internal = provider as unknown as ProviderInternals + connect(internal) + internal.webview = { postMessage: async () => undefined } + + await internal.handleSendCommand("share", "", "mid", "ses-wt") + + // session.get is called by refreshSessionDetails; the command itself + // must target the exact worktree directory, never /active/root. + expect(commandCalls).toHaveLength(1) + expect(commandCalls[0]!.directory).toBe("/repo/a/.kilo/wt") + expect(commandCalls[0]!.command).toBe("share") + // No fallback to the active root anywhere. + expect(calls.every((c) => c.directory !== "/active/root")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/language-utils.test.ts b/packages/kilo-vscode/tests/unit/language-utils.test.ts index cc1a8c286b..b4dc8e0aee 100644 --- a/packages/kilo-vscode/tests/unit/language-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/language-utils.test.ts @@ -33,6 +33,11 @@ describe("normalizeLocale", () => { expect(normalizeLocale("ko-KR")).toBe("ko") }) + it("returns 'fa' for Persian", () => { + expect(normalizeLocale("fa")).toBe("fa") + expect(normalizeLocale("fa-IR")).toBe("fa") + }) + it("returns 'no' for Norwegian Bokmål", () => { expect(normalizeLocale("nb")).toBe("no") expect(normalizeLocale("nb-NO")).toBe("no") diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index 5e8ae6f423..be6e5d816e 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { createRoot } from "solid-js" import { resolveNavigation, validateLocalSession, @@ -6,8 +7,21 @@ import { canOpenRootSession, filterUnassignedSessions, remoteSessions, + buildProjectNavOrder, + resolveProjectNav, + localNavId, + worktreeNavId, + sessionNavId, + type ProjectNavInput, LOCAL, } from "../../webview-ui/agent-manager/navigate" +import { createProjectNav, type NavTarget } from "../../webview-ui/agent-manager/project-nav" +import type { SidebarItem } from "../../webview-ui/agent-manager/section-helpers" +import type { + AgentManagerStateMessage, + AgentProjectSnapshot, + ProjectSessionInfo, +} from "../../webview-ui/src/types/messages" const ids = ["a", "b", "c", "d"] @@ -322,3 +336,391 @@ describe("remoteSessions", () => { expect(remoteSessions([], [], pending)).toEqual([]) }) }) + +describe("buildProjectNavOrder", () => { + const project = ( + p: Omit & { sessionsCollapsed?: boolean }, + ): ProjectNavInput => ({ + ...p, + sessionsCollapsed: p.sessionsCollapsed ?? false, + }) + + it("builds A Local -> A worktree -> B Local -> B worktree -> B session across expanded projects", () => { + const order = buildProjectNavOrder([ + project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }), + project({ id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [], unassigned: [{ id: "bs1" }] }), + ]) + + expect(order.map((e) => e.id)).toEqual([ + localNavId("A"), + worktreeNavId("A", "aw1"), + localNavId("B"), + worktreeNavId("B", "bw1"), + sessionNavId("B", "bs1"), + ]) + expect(order.map((e) => e.target)).toEqual([ + { projectId: "A", kind: "local" }, + { projectId: "A", kind: "worktree", worktreeId: "aw1" }, + { projectId: "B", kind: "local" }, + { projectId: "B", kind: "worktree", worktreeId: "bw1" }, + { projectId: "B", kind: "session", sessionId: "bs1" }, + ]) + }) + + it("uses project-qualified composite ids, never raw worktree/session ids", () => { + const order = buildProjectNavOrder([ + project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [{ id: "as1" }] }), + ]) + const ids = order.map((e) => e.id) + expect(ids).not.toContain("aw1") + expect(ids).not.toContain("as1") + expect(ids).toContain("A:local") + expect(ids).toContain("A:wt:aw1") + expect(ids).toContain("A:sess:as1") + }) + + it("excludes collapsed projects entirely", () => { + const order = buildProjectNavOrder([ + project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }), + project({ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], unassigned: [{ id: "cs1" }] }), + ]) + expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")]) + expect(order.some((e) => e.target.kind === "worktree" && e.target.worktreeId === "cw1")).toBe(false) + }) + + it("excludes worktrees inside collapsed sections but keeps ungrouped ones", () => { + const order = buildProjectNavOrder([ + project({ + id: "A", + expanded: true, + worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }], + sections: [{ id: "s1", collapsed: true }], + unassigned: [], + }), + ]) + expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2")]) + }) + + it("renders section members before ungrouped worktrees (matching the project body)", () => { + const order = buildProjectNavOrder([ + project({ + id: "A", + expanded: true, + worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }], + sections: [{ id: "s1", collapsed: false }], + unassigned: [], + }), + ]) + expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1"), worktreeNavId("A", "aw2")]) + }) + + it("excludes unassigned sessions when the sessions section is collapsed", () => { + const order = buildProjectNavOrder([ + project({ + id: "A", + expanded: true, + worktrees: [{ id: "aw1" }], + sections: [], + sessionsCollapsed: true, + unassigned: [{ id: "as1" }, { id: "as2" }], + }), + ]) + expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")]) + }) + + it("returns an empty order when every project is collapsed", () => { + const order = buildProjectNavOrder([ + project({ id: "A", expanded: false, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }), + ]) + expect(order).toEqual([]) + }) +}) + +describe("resolveProjectNav", () => { + // A Local -> A worktree -> B Local -> B worktree -> B session + const inputs: ProjectNavInput[] = [ + { id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], sessionsCollapsed: false, unassigned: [] }, + { + id: "B", + expanded: true, + worktrees: [{ id: "bw1" }], + sections: [], + sessionsCollapsed: false, + unassigned: [{ id: "bs1" }], + }, + ] + const order = buildProjectNavOrder(inputs) + // Collapsed project C must not appear in the order + const withCollapsed = buildProjectNavOrder([ + ...inputs, + { id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], sessionsCollapsed: false, unassigned: [] }, + ]) + + it("walks forward A Local -> A worktree -> B Local -> B worktree -> B session", () => { + let current: string | undefined = undefined + const trail: string[] = [] + for (let i = 0; i < 6; i++) { + const entry = resolveProjectNav("down", current, order) + if (!entry) break + current = entry.id + trail.push(entry.id) + } + expect(trail).toEqual([ + localNavId("A"), + worktreeNavId("A", "aw1"), + localNavId("B"), + worktreeNavId("B", "bw1"), + sessionNavId("B", "bs1"), + ]) + }) + + it("walks in reverse B session -> B worktree -> B Local -> A worktree -> A Local", () => { + let current: string | undefined = sessionNavId("B", "bs1") + const trail: string[] = [current] + for (let i = 0; i < 6; i++) { + const entry = resolveProjectNav("up", current, order) + if (!entry) break + current = entry.id + trail.push(current) + } + expect(trail).toEqual([ + sessionNavId("B", "bs1"), + worktreeNavId("B", "bw1"), + localNavId("B"), + worktreeNavId("A", "aw1"), + localNavId("A"), + ]) + }) + + it("returns undefined at the top boundary (up from first)", () => { + expect(resolveProjectNav("up", localNavId("A"), order)).toBeUndefined() + }) + + it("returns undefined at the bottom boundary (down from last)", () => { + expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined() + }) + + it("does not wrap around", () => { + expect(resolveProjectNav("up", localNavId("A"), order)).toBeUndefined() + expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined() + }) + + it("treats an unknown current as before-first (down -> first, up -> undefined)", () => { + expect(resolveProjectNav("down", "unknown", order)?.id).toBe(localNavId("A")) + expect(resolveProjectNav("up", "unknown", order)).toBeUndefined() + }) + + it("treats undefined current as before-first", () => { + expect(resolveProjectNav("down", undefined, order)?.id).toBe(localNavId("A")) + expect(resolveProjectNav("up", undefined, order)).toBeUndefined() + }) + + it("returns undefined for an empty order", () => { + expect(resolveProjectNav("down", localNavId("A"), [])).toBeUndefined() + expect(resolveProjectNav("up", undefined, [])).toBeUndefined() + }) + + it("collapsed project C is excluded from the order", () => { + expect(withCollapsed.length).toBe(order.length) + expect(withCollapsed.some((e) => e.id === worktreeNavId("C", "cw1"))).toBe(false) + // Forward walk still ends at B session, never reaching C + let current: string | undefined = undefined + let last: string | undefined + for (let i = 0; i < 10; i++) { + const entry = resolveProjectNav("down", current, withCollapsed) + if (!entry) break + current = entry.id + last = entry.id + } + expect(last).toBe(sessionNavId("B", "bs1")) + }) +}) + +describe("createProjectNav", () => { + // activate() schedules a DOM scroll via requestAnimationFrame; bun:test has + // no DOM/rAF, so stub rAF to a no-op. The atomic `post` callback fires before + // rAF, so targets are still observable. (solid-js resolves to its server + // build under bun, where createMemo is one-shot, so each helper below builds + // a fresh controller per call — mirroring one activation per keypress.) + let raf: typeof globalThis.requestAnimationFrame | undefined + beforeEach(() => { + raf = globalThis.requestAnimationFrame + globalThis.requestAnimationFrame = (() => 0) as never + }) + afterEach(() => { + globalThis.requestAnimationFrame = raf + }) + + const project = (id: string, expanded: boolean): AgentProjectSnapshot => + ({ + id, + root: `/${id}`, + label: id, + pinned: false, + active: id === "A", + expanded, + initialized: true, + trusted: true, + missing: false, + }) as AgentProjectSnapshot + + const state = ( + worktrees: { id: string; sectionId?: string }[], + sessions: { id: string; worktreeId: string | null }[], + sections: { id: string; collapsed: boolean }[] = [], + sessionsCollapsed = false, + ): AgentManagerStateMessage => + ({ + type: "agentManager.state", + worktrees: worktrees as never, + sessions: sessions as never, + sections, + sessionsCollapsed, + }) as AgentManagerStateMessage + + const session = (id: string): ProjectSessionInfo => ({ + id, + title: id, + createdAt: "", + updatedAt: "", + worktreeId: null, + }) + + // A (expanded): worktree aw1. B (expanded): worktree bw1 + unassigned bs1. + // C (collapsed): worktree cw1 — must never be reached. + const projects = () => [project("A", true), project("B", true), project("C", false)] + const states = () => ({ + A: state([{ id: "aw1" }], []), + B: state([{ id: "bw1" }], [{ id: "bs1", worktreeId: null }]), + C: state([{ id: "cw1" }], []), + }) + const sessions = () => ({ A: [], B: [session("bs1")], C: [] }) + + const run = ( + fn: (nav: ReturnType) => void, + selection: typeof LOCAL | string | null, + activeProjectId: string | undefined, + currentSessionID: string | undefined, + post: (t: NavTarget) => void = () => {}, + focus: (item: SidebarItem) => void = () => {}, + multiProject = true, + sidebarOrder: () => SidebarItem[] = () => [], + ) => + createRoot((dispose) => { + fn( + createProjectNav( + { + multiProject: () => multiProject, + sidebarOrder, + focus, + projects, + states, + sessions, + activeProjectId: () => activeProjectId, + selection: () => selection, + currentSessionID: () => currentSessionID, + }, + post, + () => {}, + ), + ) + dispose() + }) + + const stepOnce = ( + direction: "up" | "down", + selection: typeof LOCAL | string | null, + activeProjectId: string | undefined, + currentSessionID: string | undefined, + ): NavTarget | undefined => { + let posted: NavTarget | undefined + run( + (nav) => nav.step(direction), + selection, + activeProjectId, + currentSessionID, + (t) => (posted = t), + ) + return posted + } + + const jumpOnce = (index: number): NavTarget | undefined => { + let posted: NavTarget | undefined + run( + (nav) => nav.jump(index), + LOCAL, + "A", + undefined, + (t) => (posted = t), + ) + return posted + } + + it("multi-project step traverses A Local -> A worktree -> B Local -> B worktree -> B session", () => { + expect(stepOnce("down", LOCAL, "A", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) + expect(stepOnce("down", "aw1", "A", undefined)).toEqual({ projectId: "B", kind: "local" }) + expect(stepOnce("down", LOCAL, "B", undefined)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) + expect(stepOnce("down", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" }) + expect(stepOnce("down", null, "B", "bs1")).toBeUndefined() + }) + + it("multi-project step reverses B session -> B worktree -> B Local -> A worktree -> A Local", () => { + expect(stepOnce("up", null, "B", "bs1")).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) + expect(stepOnce("up", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "local" }) + expect(stepOnce("up", LOCAL, "B", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) + expect(stepOnce("up", "aw1", "A", undefined)).toEqual({ projectId: "A", kind: "local" }) + expect(stepOnce("up", LOCAL, "A", undefined)).toBeUndefined() + }) + + it("collapsed project C is never reached via jump", () => { + // Global order: A:local(0), A:aw1(1), B:local(2), B:bw1(3), B:bs1(4). + expect(jumpOnce(0)).toEqual({ projectId: "A", kind: "local" }) + expect(jumpOnce(1)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) + expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" }) + expect(jumpOnce(3)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) + expect(jumpOnce(4)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" }) + // Past the end — and C's collapsed worktree is never reachable by any index. + expect(jumpOnce(5)).toBeUndefined() + expect(jumpOnce(99)).toBeUndefined() + }) + + it("multi-project jump targets the global order by index (⌘3 = B Local)", () => { + expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" }) + }) + + it("single-project mode keeps the legacy in-process traversal and never posts activateSelection", () => { + const order: SidebarItem[] = [ + { type: "local", id: "local" }, + { type: "wt", id: "w1" }, + { type: "session", id: "s1" }, + ] + const focused: SidebarItem[] = [] + const posted: NavTarget[] = [] + const focus = (item: SidebarItem) => focused.push(item) + const post = (t: NavTarget) => posted.push(t) + run( + (nav) => nav.step("down"), + LOCAL, + "A", + undefined, + post, + focus, + false, + () => order, + ) + expect(focused.pop()).toEqual({ type: "wt", id: "w1" }) + run( + (nav) => nav.jump(2), + "w1", + "A", + undefined, + post, + focus, + false, + () => order, + ) + expect(focused.pop()).toEqual({ type: "session", id: "s1" }) + // Legacy path never dispatches the multi-project activation message. + expect(posted).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index df7a44d25d..406076ec60 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("saves selected rules and replies in the recorded SSE directory", async () => { @@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => { deniedAlways: ["rm *"], }, ]) - expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("treats an SDK-wrapped 404 while saving rules as stale", async () => { @@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) expect(permDirs.has("p1")).toBe(false) expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts new file mode 100644 index 0000000000..18c800e1bb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "bun:test" +import { messageTurns } from "../../webview-ui/src/context/session-queue" +import { transcriptRows } from "../../webview-ui/src/context/transcript-rows" +import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages" +import { + capacity, + historyAction, + previewText, + promptItems, + railEntries, +} from "../../webview-ui/src/components/chat/prompt-rail" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string, opts: Partial = {}): Message => ({ ...base, id, role: "user", ...opts }) +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) +const text = (id: string, messageID: string, value: string, opts: Partial = {}): Part => ({ + id, + messageID, + type: "text", + text: value, + ...opts, +}) +const tool = (id: string, messageID: string, title: string): Part => ({ + id, + messageID, + type: "tool", + tool: "bash", + state: { status: "completed", input: {}, output: "", title }, +}) +const lookup = (values: Record) => (id: string) => values[id] ?? [] + +describe("previewText", () => { + it("collapses whitespace and keeps plain text", () => { + expect(previewText(" hello\n\nworld ")).toBe("hello world") + }) + + it("drops fenced code blocks and inline code", () => { + expect(previewText("before `const x = 1` after\n```\nconst y = 2\n```\nend")).toBe("before after end") + }) + + it("keeps link labels but drops URLs and images", () => { + expect(previewText("see [the docs](https://example.com) and ![shot](img.png) now")).toBe("see the docs and now") + }) + + it("keeps bracket text inside inline code literal", () => { + expect(previewText("echo `[label](url)` verbatim")).toBe("echo verbatim") + }) + + it("strips heading, list, and quote markers", () => { + expect(previewText("# Title\n- one\n> two\nthree")).toBe("Title one two three") + }) +}) + +describe("promptItems", () => { + it("emits one item per non-partial user turn with prompt and answer", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const parts = { + u1: [text("up1", "u1", "add authentication")], + a1: [text("ap1", "a1", "Done, auth is wired up.")], + u2: [text("up2", "u2", "now fix the bug")], + a2: [text("ap2", "a2", "Fixed it.")], + } + const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup(parts)) + + const items = promptItems(rows) + + expect(items).toEqual([ + { key: "u1:user", turn: "u1", queued: false, prompt: "add authentication", answer: "Done, auth is wired up." }, + { key: "u2:user", turn: "u2", queued: false, prompt: "now fix the bug", answer: "Fixed it." }, + ]) + }) + + it("keeps assistant chunks of one turn joined", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "go")], + a1: [text("ap1", "a1", "First."), text("ap2", "a1", "Second.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]?.answer).toBe("First. Second.") + }) + + it("skips synthetic parts and leaves an empty answer for tool-only turns", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "run it"), text("up2", "u1", "internal", { synthetic: true })], + a1: [tool("at1", "a1", "Run tests"), text("at2", "a1", "hidden", { synthetic: true })], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "run it", answer: "" }) + }) + + it("marks queued rows", () => { + const u1 = user("u1") + const u2 = user("u2") + const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { queued: new Set(["u2"]) }) + + expect(promptItems(rows).map((item) => item.queued)).toEqual([false, true]) + }) + + it("truncates long prompts and answers", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "x".repeat(400))], + a1: [text("ap1", "a1", "y".repeat(400))], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + const [item] = promptItems(rows) + expect(item!.prompt.length).toBe(160) + expect(item!.prompt.endsWith("…")).toBe(true) + expect(item!.answer.length).toBe(220) + expect(item!.answer.endsWith("…")).toBe(true) + }) + + it("omits partial turns (assistant-only leads)", () => { + const a1 = assistant("a1", "u1") + const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [text("p1", "a1", "hello")] })) + + expect(promptItems(rows)).toEqual([]) + }) + + it("promotes the answer when the prompt carries no text (image-only message)", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [{ id: "up1", messageID: "u1", type: "file", mime: "image/png", url: "data:," } as Part], + a1: [text("ap1", "a1", "That screenshot shows the rail overlapping the gutter.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ + prompt: "That screenshot shows the rail overlapping the gutter.", + answer: "", + }) + }) + + it("leaves both empty when neither prompt nor answer has text", () => { + const u1 = user("u1") + const rows = transcriptRows(messageTurns([u1]), lookup({})) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "", answer: "" }) + }) +}) + +describe("capacity", () => { + it("counts how many ticks fit the transcript height", () => { + expect(capacity(24 + 7 * 5)).toBe(5) + expect(capacity(31)).toBe(1) + }) + + it("fits far more ticks than the navigator lists rows", () => { + // A tick is a hairline, so a sidebar-height transcript holds a whole + // session's prompts rather than the handful of card rows that fit. + expect(capacity(724)).toBe(100) + }) + + it("returns nothing usable for unmeasured or tiny transcripts", () => { + expect(capacity(0)).toBeLessThan(1) + expect(capacity(30)).toBeLessThan(1) + }) +}) + +describe("historyAction", () => { + it("loads the next page only after the previous page made progress", () => { + expect(historyAction(80, 160, true)).toBe("load") + }) + + it("jumps after the final page", () => { + expect(historyAction(160, 200, false)).toBe("jump") + }) + + it("stops instead of retrying a page that made no progress", () => { + expect(historyAction(160, 160, true)).toBe("stop") + }) +}) + +describe("railEntries", () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + key: `k${i}`, + turn: `t${i}`, + queued: false, + prompt: `p${i}`, + answer: `a${i}`, + })) + + it("passes through when everything fits", () => { + expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) + expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) + }) + + it("keeps the first and latest prompts at minimal capacity", () => { + expect(railEntries(items, 2)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("summarizes hidden loaded prompts between the first and recent prompts", () => { + expect(railEntries(items, 4)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "overflow", count: 2, index: 1 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("reserves the first entry for unloaded history", () => { + expect(railEntries(items, 4, true)).toEqual([ + { type: "history" }, + { type: "overflow", count: 3, index: 0 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("returns nothing at zero capacity", () => { + expect(railEntries(items, 0)).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-controller.test.ts b/packages/kilo-vscode/tests/unit/run-controller.test.ts new file mode 100644 index 0000000000..66d4577c70 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-controller.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import * as path from "node:path" +import { RunController, type RunTaskConfig } from "../../src/agent-manager/run/controller" +import type { RunStatus } from "../../src/agent-manager/run/manager" +import type { RunTerminalDestination } from "../../src/agent-manager/run/destination" + +const destination: RunTerminalDestination = "vscode" + +function setup() { + const root = mkdtempSync(path.join(tmpdir(), "kilo-run-controller-")) + const configs: RunTaskConfig[] = [] + const statuses: RunStatus[] = [] + const controller = new RunController({ + root: () => root, + state: () => undefined, + open: () => Promise.resolve(), + start: (config, done) => { + configs.push(config) + return Promise.resolve({ stop: () => done({ stopped: true }) }) + }, + post: (status) => statuses.push(status), + error: () => undefined, + log: () => undefined, + }) + return { root, configs, statuses, controller, cleanup: () => rmSync(root, { recursive: true, force: true }) } +} + +describe("RunController local key scoping", () => { + it("resolves a project-qualified local key to the repo root", async () => { + const { root, configs, controller, cleanup } = setup() + try { + await controller.configure() + await controller.run("prj-a:local", destination) + + expect(configs).toHaveLength(1) + expect(configs[0]!.worktreeId).toBe("prj-a:local") + expect(configs[0]!.cwd).toBe(root) + expect(configs[0]!.branch).toBe("local") + } finally { + cleanup() + } + }) + + it("keeps two projects' local runs as independent entries", async () => { + const { statuses, controller, cleanup } = setup() + try { + await controller.configure() + await controller.run("prj-a:local", destination) + await controller.run("prj-b:local", destination) + + // Starting B must not stop A: both entries report running. + const all = controller.state().runStatuses + expect(all.find((s) => s.worktreeId === "prj-a:local")?.state).toBe("running") + expect(all.find((s) => s.worktreeId === "prj-b:local")?.state).toBe("running") + + // Stopping A leaves B untouched. + controller.stop("prj-a:local") + await new Promise((resolve) => setTimeout(resolve, 10)) + const after = controller.state().runStatuses + expect(after.find((s) => s.worktreeId === "prj-a:local")?.state).not.toBe("running") + expect(after.find((s) => s.worktreeId === "prj-b:local")?.state).toBe("running") + expect(statuses.some((s) => s.worktreeId === "prj-a:local" && s.state === "stopping")).toBe(true) + } finally { + cleanup() + } + }) + + it("still treats the legacy unqualified local key as local", async () => { + const { root, configs, controller, cleanup } = setup() + try { + await controller.configure() + await controller.run("local", destination) + + expect(configs[0]!.worktreeId).toBe("local") + expect(configs[0]!.cwd).toBe(root) + expect(configs[0]!.branch).toBe("local") + } finally { + cleanup() + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-message.test.ts b/packages/kilo-vscode/tests/unit/run-message.test.ts new file mode 100644 index 0000000000..2ff281f9ae --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-message.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, mock } from "bun:test" +import type { RunController } from "../../src/agent-manager/run/controller" +import { handleRunMessage } from "../../src/agent-manager/run/message" +import type { AgentManagerInMessage } from "../../src/agent-manager/types" + +function controller() { + const run = mock(() => Promise.resolve()) + const stop = mock(() => undefined) + const configure = mock(() => Promise.resolve()) + return { + value: { run, stop, configure } as unknown as RunController, + run, + stop, + configure, + } +} + +describe("Agent Manager Run messages", () => { + it.each(["agentManager", "vscode"] as const)("forwards the %s dropdown destination", (destination) => { + const item = controller() + const msg = { + type: "agentManager.runScript", + worktreeId: "wt-1", + destination, + } satisfies AgentManagerInMessage + + expect(handleRunMessage(item.value, msg)).toBe(true) + expect(item.run).toHaveBeenCalledWith("wt-1", destination) + }) + + it("applies the project qualifier to the shared local key", () => { + const item = controller() + const qualify = (id: string) => (id === "local" ? "prj-a:local" : id) + + expect( + handleRunMessage( + item.value, + { type: "agentManager.runScript", worktreeId: "local", destination: "vscode" } as AgentManagerInMessage, + qualify, + ), + ).toBe(true) + expect(item.run).toHaveBeenCalledWith("prj-a:local", "vscode") + + expect( + handleRunMessage( + item.value, + { type: "agentManager.stopRunScript", worktreeId: "local" } as AgentManagerInMessage, + qualify, + ), + ).toBe(true) + expect(item.stop).toHaveBeenCalledWith("prj-a:local") + }) + + it("leaves worktree ids unqualified and works without a qualifier", () => { + const item = controller() + const qualify = (id: string) => (id === "local" ? `${id}:qualified` : id) + + expect( + handleRunMessage( + item.value, + { type: "agentManager.runScript", worktreeId: "wt-1", destination: "vscode" } as AgentManagerInMessage, + qualify, + ), + ).toBe(true) + expect(item.run).toHaveBeenCalledWith("wt-1", "vscode") + + expect( + handleRunMessage(item.value, { + type: "agentManager.stopRunScript", + worktreeId: "local", + } as AgentManagerInMessage), + ).toBe(true) + expect(item.stop).toHaveBeenCalledWith("local") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts index baaab57e3f..59bd3c2df8 100644 --- a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts @@ -86,7 +86,7 @@ describe("RunScriptManager", () => { let stopped = 0 await ctx.manager.start("wt-1", async () => ({ stop: () => stopped++ })) - ctx.manager.remove("wt-1") + await ctx.manager.remove("wt-1") expect(stopped).toBe(1) expect(ctx.manager.all()).toEqual([]) @@ -123,12 +123,28 @@ describe("RunScriptManager", () => { it("finish after remove does not resurrect stale state", async () => { const ctx = createManager() await ctx.manager.start("wt-1", async () => ({ stop: () => {} })) - ctx.manager.remove("wt-1") + await ctx.manager.remove("wt-1") ctx.manager.finish("wt-1", { exitCode: 0 }) expect(ctx.manager.all()).toEqual([]) }) + it("stops and disposes once when removal races startup", async () => { + const ctx = createManager() + const gate = deferred() + let stopped = 0 + let disposed = 0 + const started = ctx.manager.start("wt-1", () => gate.promise) + const removed = ctx.manager.remove("wt-1") + + gate.resolve({ stop: () => stopped++, dispose: () => disposed++ }) + await Promise.all([started, removed]) + + expect(stopped).toBe(1) + expect(disposed).toBe(1) + expect(ctx.manager.all()).toEqual([]) + }) + it("dispose tolerates handles that throw on stop", async () => { const ctx = createManager() await ctx.manager.start("wt-1", async () => ({ diff --git a/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts new file mode 100644 index 0000000000..d2a16dee95 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test" +import type { StartTask } from "../../src/agent-manager/run/controller" +import { pickRunStart } from "../../src/agent-manager/run/destination" + +describe("Run terminal destination", () => { + it("picks the adapter matching the panel dropdown destination", () => { + const handle = { stop: () => undefined, dispose: () => undefined } + const embedded: StartTask = async () => handle + const integrated: StartTask = async () => handle + + expect(pickRunStart("agentManager", embedded, integrated)).toBe(embedded) + expect(pickRunStart("vscode", embedded, integrated)).toBe(integrated) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts index d4d9072b66..445192eeff 100644 --- a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts +++ b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts @@ -91,36 +91,46 @@ describe("ensureSandbox", () => { describe("Agent Manager sandbox startup", () => { const provider = readFileSync(join(__dirname, "..", "..", "src", "agent-manager", "AgentManagerProvider.ts"), "utf8") + const flow = readFileSync(join(__dirname, "..", "..", "src", "agent-manager", "provider-multi-version.ts"), "utf8") const dialog = readFileSync( join(__dirname, "..", "..", "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8", ) test("reconciles before exposing or prompting the session", () => { - const start = provider.indexOf("private async onCreateMultiVersion") - const end = provider.indexOf("\n private ", start + 1) - const body = provider.slice(start, end) - const ensure = body.indexOf("await ensureSandbox") - const discard = body.indexOf("await this.discardWorktree", ensure) - const skip = body.indexOf("continue", discard) - const register = body.indexOf("this.registerWorktreeSession", ensure) - const ready = body.indexOf("this.notifyWorktreeReady", register) - const created = body.indexOf("created.push", ready) - const prompt = body.indexOf('type: "agentManager.sendInitialMessage"', created) - - expect(ensure).toBeGreaterThan(-1) - expect(discard).toBeGreaterThan(ensure) - expect(skip).toBeGreaterThan(discard) - expect(register).toBeGreaterThan(skip) + // In createVersion the sandbox gate runs before the session is exposed. + const start = flow.indexOf("async function createVersion") + const version = flow.slice(start, flow.indexOf("\n/**", start + 1)) + const gate = version.indexOf("await reconcileSandbox") + const register = version.indexOf("host.register", gate) + const ready = version.indexOf("host.notifyReady", register) + const created = version.indexOf("return {", ready) + expect(gate).toBeGreaterThan(-1) + expect(register).toBeGreaterThan(gate) expect(ready).toBeGreaterThan(register) expect(created).toBeGreaterThan(ready) - expect(prompt).toBeGreaterThan(created) + + // In reconcileSandbox a failed reconciliation discards the worktree and + // aborts the version before any exposure can happen. + const rbStart = flow.indexOf("async function reconcileSandbox") + const rollback = flow.slice(rbStart, flow.indexOf("\n/**", rbStart + 1)) + const attempt = rollback.indexOf("await ensureSandbox") + const discard = rollback.indexOf("await host.discard", attempt) + const abort = rollback.indexOf("return false", discard) + expect(attempt).toBeGreaterThan(-1) + expect(discard).toBeGreaterThan(attempt) + expect(abort).toBeGreaterThan(discard) + + // The created sessions feed the initial prompt phase. + const prompts = flow.slice(flow.indexOf("async function sendInitialPrompts")) + expect(prompts).toContain("buildInitialMessages(created") + expect(prompts).toContain('type: "agentManager.sendInitialMessage"') }) test("deletes the fresh branch when sandbox setup rolls back", () => { expect(provider).toContain("private async discardWorktree(id: string, dir: string, branch: string") expect(provider).toContain("removeWorktree(dir, branch)") - expect(provider).toContain("wt.result.path, wt.result.branch, session.id") + expect(flow).toContain("wt.result.path, wt.result.branch, sessionId") }) test("uses the persisted sandbox default for UI and only sends explicit overrides", () => { diff --git a/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts new file mode 100644 index 0000000000..c9f97d8b6a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { ScriptTerminalManager, type ScriptTerminalView } from "../../src/agent-manager/ScriptTerminalManager" +import { buildScriptTerminalWsUrl } from "../../src/agent-manager/script-terminal-url" +import { RunScriptManager, type RunStatus } from "../../src/agent-manager/run/manager" + +interface PtyInput { + location?: { directory?: string } + command?: string + args?: string[] + cwd?: string + env?: Record + title?: string +} + +interface PtyUpdate { + ptyID: string + location?: { directory?: string } + size?: { cols: number; rows: number } +} + +interface PtyInfo { + id: string + title: string + command: string + args: string[] + cwd: string + status: "running" | "exited" + pid: number + exitCode?: number +} + +interface PtyResponse { + data?: { location: { directory: string }; data: PtyInfo } + error?: unknown +} + +function wait(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function info(status: PtyInfo["status"] = "running", exitCode?: number): PtyInfo { + return { + id: "pty-1", + title: "Run", + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + status, + pid: 42, + ...(exitCode === undefined ? {} : { exitCode }), + } +} + +function harness(opts?: { + create?: (input: PtyInput) => Promise + get?: () => Promise + remove?: () => Promise<{ data?: unknown; error?: unknown }> +}) { + const calls: { create: PtyInput[]; get: unknown[]; update: PtyUpdate[]; remove: unknown[] } = { + create: [], + get: [], + update: [], + remove: [], + } + const snapshots: ScriptTerminalView[][] = [] + const closed: string[] = [] + const logs: string[] = [] + const client = { + v2: { + pty: { + create: async (input: PtyInput) => { + calls.create.push(input) + return opts?.create ? opts.create(input) : { data: { location: { directory: config.cwd }, data: info() } } + }, + get: async (input: unknown) => { + calls.get.push(input) + return opts?.get ? opts.get() : { data: { location: { directory: config.cwd }, data: info() } } + }, + update: async (input: PtyUpdate) => { + calls.update.push(input) + return { data: info() } + }, + remove: async (input: unknown) => { + calls.remove.push(input) + return opts?.remove ? opts.remove() : { data: undefined } + }, + }, + }, + } as unknown as KiloClient + const manager = new ScriptTerminalManager({ + getClient: () => client, + getClientAsync: async () => client, + buildWsUrl: (ptyID, cwd) => `ws://127.0.0.1:4096/api/pty/${ptyID}/connect?location=${cwd}`, + getTerminalFont: () => ({ fontFamily: "Menlo", fontSize: 12 }), + emit: (terminals) => snapshots.push(terminals), + closed: (terminalId) => closed.push(terminalId), + log: (msg) => logs.push(msg), + }) + return { manager, calls, snapshots, closed, logs } +} + +const config = { + worktreeId: "wt-1", + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" }, +} + +describe("ScriptTerminalManager", () => { + it("creates a Run PTY with explicit command settings and a safe snapshot", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + + expect(ctx.calls.create).toEqual([ + { + location: { directory: "/repo/worktree" }, + command: "bun", + args: ["run", "check"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" }, + title: "Run", + }, + ]) + expect(ctx.calls.get).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.snapshots.at(-1)).toEqual([ + expect.objectContaining({ + worktreeId: "wt-1", + kind: "run", + title: "Run", + state: "running", + font: { fontFamily: "Menlo", fontSize: 12 }, + }), + ]) + expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"command"') + expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"env"') + expect(done).toEqual([]) + }) + + it("normalizes the internal local Run key to a null external worktree id", async () => { + const ctx = harness() + + await ctx.manager.start("run", { ...config, worktreeId: "local", cwd: "/repo" }, () => undefined) + + expect(ctx.snapshots.at(-1)?.[0]?.worktreeId).toBeNull() + }) + + it("keeps identical worktree script terminals independent across projects", async () => { + let id = 0 + const ctx = harness({ + create: async () => ({ + data: { location: { directory: config.cwd }, data: { ...info(), id: `pty-${++id}` } }, + }), + get: async () => ({ + data: { location: { directory: config.cwd }, data: { ...info(), id: `pty-${id}` } }, + }), + }) + + await ctx.manager.start("setup", { ...config, projectId: "prj-a" }, () => undefined) + await ctx.manager.start("setup", { ...config, projectId: "prj-b" }, () => undefined) + + expect(ctx.snapshots.at(-1)).toEqual([ + expect.objectContaining({ projectId: "prj-a", worktreeId: "wt-1", kind: "setup" }), + expect.objectContaining({ projectId: "prj-b", worktreeId: "wt-1", kind: "setup" }), + ]) + expect(ctx.manager.active("setup", "wt-1", "prj-a")).toBe(true) + expect(ctx.manager.active("setup", "wt-1", "prj-b")).toBe(true) + + await ctx.manager.clear("setup", "wt-1", "prj-a") + expect(ctx.manager.active("setup", "wt-1", "prj-a")).toBe(false) + expect(ctx.manager.active("setup", "wt-1", "prj-b")).toBe(true) + }) + + it("builds canonical authenticated replay URLs", () => { + const value = buildScriptTerminalWsUrl( + { baseUrl: "http://127.0.0.1:4096", password: "secret" }, + "pty / 1", + "/repo/worktree", + ) + const url = new URL(value) + + expect(url.protocol).toBe("ws:") + expect(url.pathname).toBe("/api/pty/pty%20%2F%201/connect") + expect(url.searchParams.get("location[directory]")).toBe("/repo/worktree") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("replayExited")).toBe("1") + expect(url.searchParams.get("auth_token")).toBe(Buffer.from("kilo:secret").toString("base64")) + }) + + it("finishes once on a natural exit and retains the replayable terminal", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + ctx.manager.exited("pty-1", 17) + ctx.manager.exited("pty-1", 17) + + expect(done).toEqual([{ exitCode: 17 }]) + expect(ctx.calls.remove).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "exited", exitCode: 17 })]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("reconciles a PTY that exited before registration", async () => { + const ctx = harness({ + get: async () => ({ data: { location: { directory: config.cwd }, data: info("exited", 7) } }), + }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + + expect(done).toEqual([{ exitCode: 7 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 7 })]) + }) + + it("reconciles an exit event that arrives before create registration", async () => { + const gate = deferred() + let state = info() + const ctx = harness({ + create: async () => gate.promise, + get: async () => ({ data: { location: { directory: config.cwd }, data: state } }), + }) + const done: unknown[] = [] + const started = ctx.manager.start("run", config, (exit) => done.push(exit)) + + await wait() + state = info("exited", 9) + ctx.manager.exited("pty-1", 9) + gate.resolve({ data: { location: { directory: config.cwd }, data: info() } }) + await started + + expect(done).toEqual([{ exitCode: 9 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 9 })]) + }) + + it("treats an already removed backend PTY as a successful close", async () => { + const ctx = harness({ remove: async () => ({ error: { _tag: "PtyNotFoundError", status: 404 } }) }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("stops a PTY when stop races startup", async () => { + const gate = deferred() + const ctx = harness({ create: async () => gate.promise }) + const statuses: RunStatus[] = [] + const run = new RunScriptManager( + () => undefined, + (status) => statuses.push({ ...status }), + () => new Date("2026-01-02T03:04:05.000Z"), + ) + const started = run.start("wt-1", () => ctx.manager.start("run", config, (exit) => run.finish("wt-1", exit))) + + await wait() + await run.stop("wt-1") + gate.resolve({ data: { location: { directory: config.cwd }, data: info() } }) + await started + await wait() + + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(statuses.map((status) => status.state)).toEqual(["running", "stopping", "idle"]) + expect(run.status("wt-1")).toMatchObject({ state: "idle", stopped: true }) + }) + + it("intercepts resize and stops a running terminal when it closes", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + expect(ctx.manager.intercept({ type: "agentManager.terminal.resize", terminalId, cols: 120, rows: 40 })).toBe(true) + await wait() + expect(ctx.calls.update).toEqual([ + { ptyID: "pty-1", location: { directory: "/repo/worktree" }, size: { cols: 120, rows: 40 } }, + ]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("retries closure after a Run terminal removal fails", async () => { + let attempt = 0 + const ctx = harness({ + remove: async () => { + attempt++ + if (attempt === 1) return { error: new Error("still running") } + return { data: undefined } + }, + }) + + await ctx.manager.start("run", config, () => undefined) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Run terminal") + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.closed).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.calls.remove).toHaveLength(2) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("drops a retained Run terminal when the backend evicts it", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + ctx.manager.exited("pty-1", 0) + ctx.manager.deleted("pty-1") + + expect(done).toEqual([{ exitCode: 0 }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + expect(ctx.calls.remove).toEqual([]) + }) + + it("reconciles a natural exit missed during an event-stream reconnect", async () => { + let state: PtyInfo = info() + const ctx = harness({ get: async () => ({ data: { location: { directory: config.cwd }, data: state } }) }) + const done: unknown[] = [] + + await ctx.manager.start("run", config, (exit) => done.push(exit)) + state = info("exited", 23) + await ctx.manager.sync() + + expect(done).toEqual([{ exitCode: 23 }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 23 })]) + }) + + it("clears retained exited terminals by worktree context", async () => { + const ctx = harness() + + await ctx.manager.start("run", config, () => undefined) + ctx.manager.exited("pty-1", 0) + + expect(await ctx.manager.clear("run", "wt-1")).toBe(true) + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("replays the full retained snapshot after a webview reload", async () => { + const ctx = harness() + + await ctx.manager.start("run", config, () => undefined) + const first = ctx.snapshots.at(-1) + ctx.manager.snapshot() + + expect(ctx.snapshots.at(-1)).toEqual(first) + }) +}) + +describe("ScriptTerminalManager Setup kind", () => { + it("creates a Setup PTY labeled Setup and tracks its kind", async () => { + const ctx = harness() + + await ctx.manager.start("setup", config, () => undefined) + + expect(ctx.calls.create[0]?.title).toBe("Setup") + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ kind: "setup", title: "Setup", state: "running" })]) + expect(ctx.manager.active("setup", "wt-1")).toBe(true) + expect(ctx.manager.active("run", "wt-1")).toBe(false) + }) + + it("ignores a user close while Setup is running", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.calls.remove).toEqual([]) + expect(ctx.closed).toEqual([]) + expect(done).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "running" })]) + expect(ctx.logs.some((msg) => msg.includes("Ignored close"))).toBe(true) + }) + + it("force-stops a running Setup when the worktree is cleared", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + + expect(await ctx.manager.clear("setup", "wt-1")).toBe(true) + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + expect(ctx.manager.active("setup", "wt-1")).toBe(false) + }) + + it("closes an exited Setup terminal and reports inactive", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + ctx.manager.exited("pty-1", 0) + + expect(ctx.manager.active("setup", "wt-1")).toBe(false) + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(done).toEqual([{ exitCode: 0 }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("retains the tab as failed when killed, and survives the backend deleted event", async () => { + let removes = 0 + const ctx = harness({ + remove: async () => { + removes += 1 + // The first remove (the kill itself) succeeds; the PTY is gone for + // any later remove, which must still close the retained tab. + return removes > 1 ? { error: { _tag: "PtyNotFoundError" } } : { data: undefined } + }, + }) + const done: unknown[] = [] + + const handle = await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + + handle.kill?.("Setup script timed out after 5 minutes") + await wait() + + // The process tree is killed but the tab keeps its partial output. + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ error: "Setup script timed out after 5 minutes" }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + // The backend confirming the deletion must not drop the retained tab. + ctx.manager.deleted("pty-1") + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + // Closing the retained tab still works when the PTY is already gone. + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("stops a running Setup when the user deliberately stops it", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + + expect(ctx.manager.intercept({ type: "agentManager.terminal.stop", terminalId })).toBe(true) + await wait() + + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.closed).toEqual([terminalId]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 8dd06b6dd6..93b8475e5f 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -138,6 +138,13 @@ describe("terminal", () => { ).toBe("error") }) + it("hides superseded turns that handed off to a queued follow-up", () => { + expect( + terminal({ reason: "superseded", messages: [message("tool-calls")], todos: [todo("pending")] }), + ).toBeUndefined() + expect(terminal({ reason: "superseded", messages: [message("unknown")], todos: [] })).toBeUndefined() + }) + it("reports only the latest assistant finish reason", () => { const user: Message = { id: "u1", sessionID: "s1", role: "user", createdAt: new Date(1).toISOString() } expect(terminal({ reason: "completed", messages: [message("length"), user], todos: [] })?.finish).toBeUndefined() diff --git a/packages/kilo-vscode/tests/unit/session-search.test.ts b/packages/kilo-vscode/tests/unit/session-search.test.ts new file mode 100644 index 0000000000..0bae8bb682 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-search.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test" +import { handleSessionSearch } from "../../src/kilo-provider/session-search" + +type Query = Record + +function stub(data: Array> | Error) { + const calls: Query[] = [] + const client = { + experimental: { + session: { + list: async (query: Query) => { + calls.push(query) + if (data instanceof Error) throw data + return { data } + }, + }, + }, + } + return { calls, client } +} + +function session(id: string, title: string, updated: number, worktreeName?: string) { + return { id, title, time: { updated }, worktreeName } +} + +describe("handleSessionSearch", () => { + it("lists root sessions across the worktree family for the resolved directory", async () => { + const { calls, client } = stub([session("ses_a", "Alpha", 2, "neon-author")]) + const posted: unknown[] = [] + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r1", sessionID: "ses_current" }, + dir: (id) => (id === "ses_current" ? "/repo/.kilo/worktrees/wt-1" : "/repo"), + post: (msg) => posted.push(msg), + }) + + expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }]) + expect(posted).toEqual([ + { + type: "sessionSearchResult", + sessions: [{ id: "ses_a", title: "Alpha", updated: 2, worktreeName: "neon-author" }], + requestId: "r1", + }, + ]) + }) + + it("falls back to the current and context sessions for directory resolution", async () => { + const { calls, client } = stub([]) + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r2" }, + current: "ses_current", + context: "ses_context", + dir: (id) => `/dir/${id}`, + post: () => {}, + }) + + expect(calls[0]?.directory).toBe("/dir/ses_current") + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r3" }, + context: "ses_context", + dir: (id) => `/dir/${id}`, + post: () => {}, + }) + + expect(calls[1]?.directory).toBe("/dir/ses_context") + }) + + it("excludes the given session and sessions without titles", async () => { + const { client } = stub([ + session("ses_keep", "Keep", 3), + session("ses_exclude", "Excluded", 2), + session("ses_untitled", "", 1), + ]) + const posted: Array<{ sessions: Array<{ id: string }> }> = [] + + await handleSessionSearch({ + client: client as never, + message: { requestId: "r4" }, + dir: () => "/repo", + exclude: "ses_exclude", + post: (msg) => posted.push(msg as never), + }) + + expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"]) + }) + + it("posts an empty result when the client is missing or the list fails", async () => { + const posted: unknown[] = [] + + await handleSessionSearch({ + client: null, + message: { requestId: "r5" }, + dir: () => "/repo", + post: (msg) => posted.push(msg), + }) + + const failing = stub(new Error("boom")) + await handleSessionSearch({ + client: failing.client as never, + message: { requestId: "r6" }, + dir: () => "/repo", + post: (msg) => posted.push(msg), + }) + + expect(posted).toEqual([ + { type: "sessionSearchResult", sessions: [], requestId: "r5" }, + { type: "sessionSearchResult", sessions: [], requestId: "r6" }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts index 4e763196bf..5669acaeac 100644 --- a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from "bun:test" import path from "node:path" import { Project, SyntaxKind } from "ts-morph" import { SessionTerminalManager, type TerminalHost } from "../../src/agent-manager/SessionTerminalManager" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" const ROOT = path.resolve(import.meta.dir, "../..") const FILE = path.join(ROOT, "src/agent-manager/SessionTerminalManager.ts") @@ -130,8 +131,8 @@ describe("SessionTerminalManager structure", () => { expect(text).toContain("panel command registration skipped") }) - it("resolves the session that owns the active managed terminal", () => { - const text = body("activeSession") + it("resolves the key that owns the active managed terminal", () => { + const text = body("activeKey") expect(text).toContain("this.host.activeTerminal()") expect(text).toContain("entry.terminal === active") }) @@ -139,7 +140,8 @@ describe("SessionTerminalManager structure", () => { it("rejects context capture from another managed session", () => { const text = body("prepareContext") expect(text).toContain("this.showExisting(sessionId)") - expect(text).toContain("this.activeSession()") + expect(text).toContain("this.activeKey()") + expect(text).toContain("SessionTerminalManager.sessionKey(sessionId)") }) }) @@ -162,3 +164,74 @@ describe("SessionTerminalManager command restoration", () => { state.manager.dispose() }) }) + +describe("SessionTerminalManager worktree terminals", () => { + function scene(opts: { worktreePath?: string; repoPath?: string } = {}) { + const created: Array<{ cwd: string; name: string }> = [] + const warnings: string[] = [] + let shown = 0 + const host: TerminalHost = { + createTerminal(o) { + created.push(o) + return { + show: () => shown++, + dispose() {}, + exitStatus: undefined, + } + }, + activeTerminal: () => undefined, + repoPath: () => opts.repoPath, + showWarning: (msg) => warnings.push(msg), + setContext() {}, + onTerminalClosed: () => ({ dispose() {} }), + onActiveTerminalChanged: () => ({ dispose() {} }), + registerCommand: () => ({ dispose() {} }), + executeCommand: () => Promise.resolve(), + } + const state = { + getWorktree: (id: string) => + opts.worktreePath ? { id, path: opts.worktreePath, branch: "feature/x" } : undefined, + } as unknown as WorktreeStateManager + const manager = new SessionTerminalManager(() => {}, host) + return { manager, state, created, warnings, shown: () => shown } + } + + it("creates a terminal rooted at the worktree path", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" }) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toEqual([{ cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }]) + expect(s.shown()).toBe(1) + }) + + it("reuses the live terminal on repeat calls", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1" }) + s.manager.showWorktreeTerminal("wt-1", s.state) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toHaveLength(1) + expect(s.shown()).toBe(2) + }) + + it("keeps session and worktree terminal keys in separate namespaces", () => { + const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" }) + s.manager.showTerminal("worktree:wt-1", undefined) + s.manager.showWorktreeTerminal("wt-1", s.state) + expect(s.created).toEqual([ + { cwd: "/repo", name: "Agent: local" }, + { cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }, + ]) + expect(s.shown()).toBe(2) + }) + + it("falls back to the repo root when the worktree is unknown", () => { + const s = scene({ repoPath: "/repo" }) + s.manager.showWorktreeTerminal("gone", s.state) + expect(s.created).toEqual([{ cwd: "/repo", name: "Agent: worktree" }]) + }) + + it("warns and creates nothing when no cwd resolves", () => { + const s = scene({}) + s.manager.showWorktreeTerminal("gone", s.state) + expect(s.created).toHaveLength(0) + expect(s.warnings).toHaveLength(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/setup-script-task.test.ts b/packages/kilo-vscode/tests/unit/setup-script-task.test.ts new file mode 100644 index 0000000000..7714cad6c2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/setup-script-task.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import type { ScriptTerminalManager } from "../../src/agent-manager/ScriptTerminalManager" +import { createSetupScriptTask, pickSetupTask, runWorktreeSetupScript } from "../../src/agent-manager/setup-script-task" +import { SetupScriptService } from "../../src/agent-manager/SetupScriptService" +import type { RunTask } from "../../src/agent-manager/SetupScriptRunner" +import type { AgentManagerOutMessage } from "../../src/agent-manager/types" + +interface StartCall { + kind: string + config: { worktreeId: string; command: string; args: string[]; cwd: string; env: Record } + done: (exit: { exitCode?: number; stopped?: boolean; error?: string }) => void +} + +function wait(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function harness(opts?: { + trusted?: boolean + timeoutMs?: number + gate?: ReturnType> + startError?: Error +}) { + const starts: StartCall[] = [] + const stops: string[] = [] + const manager = { + start: (kind: string, config: StartCall["config"], done: StartCall["done"]) => { + starts.push({ kind, config, done }) + if (opts?.startError) return Promise.reject(opts.startError) + if (opts?.gate) return opts.gate.promise + stops.length = 0 + return Promise.resolve({ + stop: () => { + stops.push(config.worktreeId) + }, + }) + }, + } as unknown as ScriptTerminalManager + const logs: string[] = [] + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => opts?.trusted ?? true, + log: (msg) => logs.push(msg), + timeoutMs: opts?.timeoutMs, + env: async () => ({ PATH: "/bin" }), + }) + return { task, starts, stops, logs, manager } +} + +const config = { + command: "sh", + args: ["/repo/.kilo/setup-script"], + cwd: "/repo/worktree", + env: { WORKTREE_PATH: "/repo/worktree", REPO_PATH: "/repo" }, +} + +describe("createSetupScriptTask", () => { + it("starts a setup script terminal with the composed environment", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + + expect(ctx.starts).toHaveLength(1) + expect(ctx.starts[0]?.kind).toBe("setup") + expect(ctx.starts[0]?.config).toEqual({ + worktreeId: "wt-1", + command: "sh", + args: ["/repo/.kilo/setup-script"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree", REPO_PATH: "/repo" }, + }) + + ctx.starts[0]?.done({ exitCode: 0 }) + await expect(result).resolves.toBe(0) + }) + + it("resolves a nonzero exit code so the runner can report it", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + ctx.starts[0]?.done({ exitCode: 42 }) + + await expect(result).resolves.toBe(42) + }) + + it("rejects when the terminal reports an execution error", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + ctx.starts[0]?.done({ error: "Setup terminal was removed before it exited" }) + + await expect(result).rejects.toThrow("Setup terminal was removed before it exited") + }) + + it("rejects when the terminal is stopped externally", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + ctx.starts[0]?.done({ stopped: true }) + + await expect(result).rejects.toThrow("Setup script was stopped") + }) + + it("rejects when the backend refuses to create the terminal", async () => { + const ctx = harness({ startError: new Error("Not connected to CLI backend") }) + const result = ctx.task(config) + + await expect(result).rejects.toThrow("Not connected to CLI backend") + }) + + it("times out, rejects, and stops the process tree", async () => { + const ctx = harness({ timeoutMs: 5 }) + const result = ctx.task(config) + await wait() + + await expect(result).rejects.toThrow("Setup script timed out after 5 minutes") + expect(ctx.stops).toEqual(["wt-1"]) + + // A late exit after the timeout must not settle the promise again. + ctx.starts[0]?.done({ exitCode: 0 }) + }) + + it("times out by killing with the reason so the tab stays reviewable", async () => { + const kills: string[] = [] + const stops: string[] = [] + const manager = { + start: async (kind: string, cfg: StartCall["config"], done: StartCall["done"]) => ({ + stop: () => { + stops.push(cfg.worktreeId) + }, + kill: (reason: string) => { + kills.push(reason) + }, + }), + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 5, + env: async () => ({}), + }) + const result = task(config) + + await expect(result).rejects.toThrow("Setup script timed out after 5 minutes") + expect(kills).toEqual(["Setup script timed out after 5 minutes"]) + expect(stops).toEqual([]) + }) + + it("stops a handle that arrives after the timeout already fired", async () => { + const gate = deferred<{ stop(): void }>() + const stops: string[] = [] + const starts: StartCall[] = [] + const manager = { + start: (kind: string, config: StartCall["config"], done: StartCall["done"]) => { + starts.push({ kind, config, done }) + return gate.promise + }, + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 5, + env: async () => ({}), + }) + const result = task(config) + + await expect(result).rejects.toThrow("Setup script timed out after 5 minutes") + gate.resolve({ + stop: () => { + stops.push("wt-1") + }, + }) + await wait() + expect(stops).toEqual(["wt-1"]) + }) + + it("keeps the retained terminal when the script exits during creation", async () => { + const stops: string[] = [] + const manager = { + start: async (kind: string, config: StartCall["config"], done: StartCall["done"]) => { + // Fast scripts: the PTY exit is reconciled before start() resolves. + done({ exitCode: 0 }) + return { + stop: () => { + stops.push(config.worktreeId) + }, + } + }, + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 60_000, + env: async () => ({}), + }) + + await expect(task(config)).resolves.toBe(0) + await wait() + // The exited PTY must not be stopped: its tab retains the output. + expect(stops).toEqual([]) + }) + + it("recovers a lost exit event through the reconcile watchdog", async () => { + let syncs = 0 + const starts: StartCall[] = [] + const manager = { + start: async (kind: string, cfg: StartCall["config"], done: StartCall["done"]) => { + starts.push({ kind, config: cfg, done }) + return { stop: () => undefined } + }, + sync: async () => { + syncs += 1 + // The reconcile discovers the exit the event stream missed. + if (syncs === 2) starts[0]?.done({ exitCode: 7 }) + }, + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 60_000, + watchdogMs: 5, + env: async () => ({}), + }) + + await expect(task(config)).resolves.toBe(7) + expect(syncs).toBeGreaterThanOrEqual(2) + // The watchdog stops once the script settles. + const seen = syncs + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(syncs).toBe(seen) + }) + + it("refuses to run in an untrusted workspace without touching the backend", async () => { + const ctx = harness({ trusted: false }) + + await expect(ctx.task(config)).rejects.toThrow("Trust the workspace before running setup scripts") + expect(ctx.starts).toEqual([]) + }) +}) + +describe("pickSetupTask", () => { + function pick(opts: { destination: "vscode" | "agentManager"; worktreeId?: string; trusted?: boolean }) { + const vscode: RunTask = async () => 0 + const ctx = harness({ trusted: opts.trusted }) + const task = pickSetupTask({ + destination: opts.destination, + worktreeId: opts.worktreeId, + trusted: () => opts.trusted ?? true, + manager: ctx.manager, + log: () => undefined, + vscode, + env: async () => ({}), + }) + return { task, vscode, ctx } + } + + it("uses the integrated task runner when the destination is the VS Code terminal", () => { + const { task, vscode, ctx } = pick({ destination: "vscode", worktreeId: "wt-1" }) + expect(task).toBe(vscode) + expect(ctx.starts).toEqual([]) + }) + + it("uses the integrated task runner without a worktree id", () => { + const { task, vscode } = pick({ destination: "agentManager" }) + expect(task).toBe(vscode) + }) + + it("does not redirect an untrusted embedded selection to VS Code", async () => { + const { task, vscode } = pick({ destination: "agentManager", worktreeId: "wt-1", trusted: false }) + expect(task).not.toBe(vscode) + await expect(task(config)).rejects.toThrow("Trust the workspace before running setup scripts") + }) + + it("uses the embedded script terminal when the Agent Manager panel is selected", async () => { + const { task, vscode, ctx } = pick({ destination: "agentManager", worktreeId: "wt-1" }) + expect(task).not.toBe(vscode) + + const result = task(config) + await wait() + expect(ctx.starts[0]?.kind).toBe("setup") + ctx.starts[0]?.done({ exitCode: 0 }) + await expect(result).resolves.toBe(0) + }) +}) + +describe("runWorktreeSetupScript", () => { + function root(script: boolean): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-setup-flow-")) + if (!script) return dir + fs.mkdirSync(path.join(dir, ".kilo"), { recursive: true }) + fs.writeFileSync(path.join(dir, ".kilo", "setup-script"), "#!/bin/sh\nexit 0\n") + return dir + } + + function flow( + script: boolean, + opts?: { destination?: "vscode" | "agentManager"; code?: number; projectId?: string }, + ) { + const posted: AgentManagerOutMessage[] = [] + const runs: string[] = [] + const ctx = harness() + const vscode: RunTask = async (cfg) => { + runs.push(cfg.cwd) + return opts?.code ?? 0 + } + const input = { + service: new SetupScriptService(root(script)), + destination: opts?.destination ?? ("vscode" as const), + projectId: opts?.projectId, + worktreeId: "wt-1", + trusted: () => true, + manager: ctx.manager, + log: () => undefined, + vscode, + env: async () => ({}), + post: (message: AgentManagerOutMessage) => posted.push(message), + } + return { input, posted, runs, ctx } + } + + it("posts progress and executes through the picked runner", async () => { + const scene = flow(true, { code: 0 }) + await runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }) + + expect(scene.posted).toEqual([ + { + type: "agentManager.worktreeSetup", + status: "creating", + message: "Running setup script...", + worktreeId: "wt-1", + }, + ]) + expect(scene.runs).toEqual(["/repo/worktree"]) + }) + + it("stamps progress and embedded terminals with the owning project", async () => { + const scene = flow(true, { destination: "agentManager", projectId: "prj-a" }) + const result = runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(scene.posted[0]).toMatchObject({ projectId: "prj-a", worktreeId: "wt-1" }) + expect(scene.ctx.starts[0]?.config).toMatchObject({ projectId: "prj-a", worktreeId: "wt-1" }) + scene.ctx.starts[0]?.done({ exitCode: 0 }) + await result + }) + + it("stays silent when no setup script is configured", async () => { + const scene = flow(false) + await runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }) + + expect(scene.posted).toEqual([]) + expect(scene.runs).toEqual([]) + }) + + it("keeps worktree creation best-effort when the script fails", async () => { + const scene = flow(true, { code: 3 }) + await expect( + runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }), + ).resolves.toBeUndefined() + expect(scene.runs).toEqual(["/repo/worktree"]) + expect(scene.posted).toContainEqual({ + type: "agentManager.worktreeSetup", + status: "error", + message: "Setup script exited with code 3", + worktreeId: "wt-1", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts b/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts new file mode 100644 index 0000000000..59888079ed --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "bun:test" +import { WorktreeDiffController } from "../../src/agent-manager/worktree-diff-controller" +import type { DiffSourceCatalog } from "../../src/diff/sources/catalog" +import type { DiffSource } from "../../src/diff/sources/types" +import type { PanelContext } from "../../src/diff/types" +import type { GitOps } from "../../src/agent-manager/GitOps" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +// Records every PanelContext handed to catalog.build so tests can assert which +// base branch the active source was (re)built with. The controller, scope +// resolution, and SourceController lifecycle under test are all real. +// Contexts are worktree ids (the sidebar selection), not session ids. +function make(onFetch?: (n: number) => Promise) { + const builds: { id: string; ctx: PanelContext }[] = [] + let fetches = 0 + const catalog = { + build: (id: string, ctx: PanelContext): DiffSource => { + builds.push({ id, ctx }) + return { + descriptor: { id, type: "workspace", group: "Git", capabilities: { revert: true, comments: true } }, + async fetch() { + await onFetch?.(++fetches) + return { diffs: [] } + }, + } + }, + } as unknown as DiffSourceCatalog + + const state = { + getSession: (id: string) => (id === "s1" ? { id: "s1", worktreeId: "w1", createdAt: "" } : undefined), + getWorktree: (id: string) => + id === "w1" ? { id: "w1", path: "/wt", parentBranch: "main", remote: "origin" } : undefined, + } as unknown as WorktreeStateManager + + const controller = new WorktreeDiffController({ + getState: () => state, + getRoot: () => "/repo", + getStateReady: () => undefined, + catalog, + git: {} as GitOps, + localDiffFile: async () => null, + post: () => {}, + log: () => {}, + }) + return { controller, builds } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +async function waitFor(cond: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (cond()) return + await tick() + } + throw new Error("waitFor timed out") +} + +describe("WorktreeDiffController.setBase", () => { + it("rebuilds the active source against the overridden base branch", async () => { + const { controller, builds } = make() + controller.start("w1#branch") + await waitFor(() => builds.length === 1) + expect(builds[0]!.ctx.dir).toBe("/wt") + expect(builds[0]!.ctx.baseBranch).toBe("origin/main") + + await controller.setBase("w1#branch", "feature-x") + expect(builds.length).toBe(2) + expect(builds[1]!.ctx.dir).toBe("/wt") + expect(builds[1]!.ctx.baseBranch).toBe("feature-x") + + // Clearing the override falls back to the recorded parent ref. + await controller.setBase("w1#branch", undefined) + expect(builds.length).toBe(3) + expect(builds[2]!.ctx.baseBranch).toBe("origin/main") + + controller.stop() + }) + + it("stores the override without rebuilding when the context isn't active", async () => { + const { controller, builds } = make() + + await controller.setBase("w1#branch", "feature-x") + expect(builds.length).toBe(0) + + // The next activation of that context resolves the stored override. + controller.start("w1#branch") + await waitFor(() => builds.length === 1) + expect(builds[0]!.ctx.baseBranch).toBe("feature-x") + + controller.stop() + }) + + it("keeps watching when the base changes during the initial fetch", async () => { + // Hold the first activation's fetch in flight, simulating a slow worktree + // diff. isPolling is still false in this window, but the watch intent must + // survive the base change rather than downgrading the panel to one-shot. + let release: () => void = () => {} + const gate = new Promise((resolve) => (release = resolve)) + const { controller, builds } = make(async (n) => { + if (n === 1) await gate + }) + + controller.start("w1#branch") + await waitFor(() => builds.length === 1) + + const change = controller.setBase("w1#branch", "feature-x") + release() + await change + expect(builds.length).toBe(2) + expect(builds[1]!.ctx.baseBranch).toBe("feature-x") + + // Polling survives: start() early-returns for an id that is already + // watched. A downgraded one-shot panel would re-activate and rebuild here. + controller.start("w1#branch") + await tick() + expect(builds.length).toBe(2) + + controller.stop() + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ce6f795941..b1adf20541 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -11,11 +11,13 @@ import { onCleanup, type Component, type JSX, + type Setter, } from "solid-js" import type { AgentManagerRepoInfoMessage, AgentManagerWorktreeSetupMessage, AgentManagerStateMessage, + ExtensionMessage, AgentManagerKeybindingsMessage, AgentManagerMultiVersionProgressMessage, AgentManagerSendInitialMessage, @@ -23,9 +25,9 @@ import type { AgentManagerWorktreeDiffMessage, AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, + AgentManagerWorktreeDiffNoticeMessage, + AgentManagerDiffBranchesMessage, AgentManagerApplyWorktreeDiffResultMessage, - AgentManagerApplyWorktreeDiffStatus, - AgentManagerApplyWorktreeDiffConflict, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, WorktreeFileDiff, @@ -35,40 +37,28 @@ import type { RunStatus, PRStatus, AgentManagerPRStatusMessage, + AgentManagerProjectsMessage, + AgentProjectSnapshot, ManagedSessionState, SectionState, SessionInfo, SessionCreatedMessage, BranchInfo, + TerminalDestination, } from "../src/types/messages" import { IndexingProvider } from "../src/context/indexing" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, -} from "@thisbeyond/solid-dnd" +import {} from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" -import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" -import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" -import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" -import { Code } from "@kilocode/kilo-ui/code" -import { Diff } from "@kilocode/kilo-ui/diff" -import { File } from "@kilocode/kilo-ui/file" -import { Toast, showToast } from "@kilocode/kilo-ui/toast" +import { showToast } from "@kilocode/kilo-ui/toast" import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { Popover } from "@kilocode/kilo-ui/popover" import { VSCodeProvider, useVSCode } from "../src/context/vscode" import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" @@ -82,11 +72,24 @@ import { MemoryProvider } from "../src/context/memory" import { SessionProvider, useSession } from "../src/context/session" import { AgentRequirementsProvider } from "../src/context/agent-requirements" import { WorktreeModeProvider } from "../src/context/worktree-mode" +import { ProviderShell } from "../src/context/provider-shell" import { ChatView } from "../src/components/chat" -import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" -import { DataBridge, MermaidDownloadBridge } from "../src/App" +import { ProjectList } from "./ProjectList" +import { SidebarBody } from "./SidebarBody" +import { TabBar } from "./TabBar" +import { createProjectLive } from "./project/live" +import { createProjectSessionsLive } from "./project/sessions-live" +import { applyProjectSelection, createTargetRememberer } from "./project/selection" +import { createLocalSessions, persistLocalTabs } from "./project/local-tabs" +import { createProjectRegistry, type PersistedProjectTabs } from "./project/registry" +import type { WorktreeBusyState } from "./project/store" +import { rememberTarget, restoreProjectTarget } from "./project/restore" +import { createProjectStateRouter } from "./project/state" +import { applyRunStatus } from "./project/run-status" +import { selectLocalAction, selectWorktreeAction } from "./selection-actions" +import { DataBridge } from "../src/App" import { LanguageBridge } from "../src/context/language-bridge" import { useLanguage } from "../src/context/language" import { createTabFocus } from "../src/utils/tab-navigation" @@ -99,6 +102,7 @@ import { focusChatSearch, LOCAL, } from "./navigate" +import { createProjectNav } from "./project-nav" import { addPendingTab as addLocalPendingTab, nextTabAfterClose, @@ -126,23 +130,25 @@ import { createTerminalHandlers, createTerminalMessageHandler, createSideTerminal, + createAmbientSetup, + hasSetupTerminal, + showTerminalStack, + readSavedDestination, + resolveRunScriptRequest, + resolveVscodeTerminalRequest, } from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" -import { ApplyDialog } from "./ApplyDialog" -import { groupApplyConflicts } from "./apply-conflicts" +import { createApplyToLocal } from "./apply-to-local" +import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs" 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 { WorktreeSectionActions } from "./WorktreeSectionActions" import { BranchSelect } from "../src/components/shared/BranchSelect" -import { WorktreeItem } from "./WorktreeItem" -import { UnassignedSessionsSection } from "./UnassignedSessionsSection" -import SectionHeader from "./SectionHeader" import { randomColor } from "./section-colors" import { createNewTaskDrafts } from "./new-task-drafts" import { @@ -154,9 +160,12 @@ import { isGroupEnd, type TopLevelItem, } from "./section-helpers" -import { sectionAwareDetector } from "./section-dnd" -import { ConstrainDragXAxis } from "./constrain-drag-x" +import {} from "./section-dnd" +import {} from "./constrain-drag-x" import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" +import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" +import { scopeCapabilities } from "./diff-scope-state" +import { createDiffReviewScope } from "./diff-review-scope" import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" @@ -178,17 +187,6 @@ interface SetupState { errorCode?: string } -interface WorktreeBusyState { - reason: "setting-up" | "deleting" - message?: string - branch?: string -} - -interface ApplyState { - status: AgentManagerApplyWorktreeDiffStatus - message: string - conflicts: AgentManagerApplyWorktreeDiffConflict[] -} /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null type SidePanel = "diff" | "pr" | "terminal" | null @@ -234,18 +232,34 @@ const AgentManagerContent: Component = () => { const [kb, setKb] = createSignal>(defaultBindings) const [setup, setSetup] = createSignal({ active: false, message: "" }) - const [worktrees, setWorktrees] = createSignal([]) - const [managedSessions, setManagedSessions] = createSignal([]) + const worktrees = () => registry.active().worktrees() + const setWorktrees = (v: Parameters>[0]) => registry.active().setWorktrees(v) + const managedSessions = () => registry.active().managedSessions() + const setManagedSessions = (v: Parameters>[0]) => + registry.active().setManagedSessions(v) const [selection, setSelection] = createSignal(LOCAL) const metrics = tracker(vscode) const [repoBranch, setRepoBranch] = createSignal() - const [busyWorktrees, setBusyWorktrees] = createSignal>(new Map()) - const [staleWorktreeIds, setStaleWorktreeIds] = createSignal>(new Set()) + const busyWorktrees = () => registry.active().busy() + const setBusyWorktrees: Setter> = (v) => registry.active().setBusy(v) + const staleWorktreeIds = () => registry.active().staleWorktreeIds() + const setStaleWorktreeIds: Setter> = (v) => registry.active().setStaleWorktreeIds(v) + /** True while the ⌘/Ctrl jump modifier is held — reveals the ⌘1-9 badges on all sidebar items. */ + const [held, setHeld] = createSignal(false) const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) const [sessionsLoaded, setSessionsLoaded] = createSignal(false) const [isGitRepo, setIsGitRepo] = createSignal(true) const [repoDetectedBranch, setRepoDetectedBranch] = createSignal() - const [defaultBaseBranch, setDefaultBaseBranch] = createSignal() + const defaultBaseBranch = () => registry.active().defaultBaseBranch() + const setDefaultBaseBranch = (v: Parameters>[0]) => + registry.active().setDefaultBaseBranch(v) + const [projectList, setProjectList] = createSignal([]) + const [multiProject, setMultiProject] = createSignal(false) + const [currentProjectId, setCurrentProjectId] = createSignal() + const [projectStates, setProjectStates] = createSignal>({}) + const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId() + const isActivePayload = (pid: string | undefined) => + projectList().length === 0 || pid === undefined || pid === activeProjectId() const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main" const hasConfiguredBranch = () => !!defaultBaseBranch() @@ -255,13 +269,20 @@ const AgentManagerContent: Component = () => { const MAX_SIDEBAR_WIDTH_RATIO = 0.4 // Recover persisted local session IDs from webview state - const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>() - const [localSessionIDs, setLocalSessionIDs] = createSignal(persisted?.localSessionIDs ?? []) + const persisted = vscode.getState() + const registry = createProjectRegistry({ + persisted: persisted ?? {}, + activeId: () => currentProjectId() ?? "single", + }) + const localSessionIDs = () => registry.active().tabs.ids() + const setLocalSessionIDs = (next: string[] | ((prev: string[]) => string[])) => registry.active().tabs.set(next) /** Remove a session ID from the local tab (no-op if absent). */ const evictLocal = (sid: string) => setLocalSessionIDs((prev) => (prev.includes(sid) ? prev.filter((id) => id !== sid) : prev)) const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH) - const [sessionsCollapsed, setSessionsCollapsed] = createSignal(true) + const sessionsCollapsed = () => registry.active().sessionsCollapsed() ?? true + const setSessionsCollapsed = (v: Parameters>[0]) => + registry.active().setSessionsCollapsed(typeof v === "function" ? v(sessionsCollapsed()) : v) const toggleSessions = () => { const collapsed = !sessionsCollapsed() setSessionsCollapsed(collapsed) @@ -271,7 +292,8 @@ const AgentManagerContent: Component = () => { const sidebarCollapsed = sidebar.collapsed const expandSidebar = sidebar.expand const toggleSidebar = sidebar.toggle - const [sections, setSections] = createSignal([]) + const sections = () => registry.active().sections() + const setSections = (v: Parameters>[0]) => registry.active().setSections(v) // rAF coalescing for resize handlers — at most one signal write per frame let sidebarRaf: number | undefined @@ -282,9 +304,11 @@ const AgentManagerContent: Component = () => { const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" - const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) - const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const diffs = createWorktreeDiffs(vscode) + const diffDatas = diffs.diffDatas + const diffLoading = diffs.diffLoading + const setDiffLoading = diffs.setDiffLoading + const diffNotices = diffs.diffNotices // The diff and terminal panels each remember their own width: a diff // benefits from half the window, a terminal only needs about a third. const TERMINAL_MIN_WIDTH = 360 @@ -320,31 +344,51 @@ const AgentManagerContent: Component = () => { const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") const markdown = createMarkdownRender(vscode) // Per-worktree git stats (diff additions/deletions, commits missing from origin) - const [worktreeStats, setWorktreeStats] = createSignal>({}) + const worktreeStats = () => registry.active().worktreeStats() // Per-worktree PR status data - const [prStatuses, setPrStatuses] = createSignal>({}) + const prStatuses = () => registry.active().prStatuses() - const [runStatuses, setRunStatuses] = createSignal>({}) - const [runScriptConfigured, setRunScriptConfigured] = createSignal(false) + const runStatuses = () => registry.active().runStatuses() + const setRunStatuses: Setter> = (v) => registry.active().setRunStatuses(v) + const runScriptConfigured = () => registry.active().runScriptConfigured() + const setRunScriptConfigured = (v: Parameters>[0]) => registry.active().setRunScriptConfigured(v) // Local repo git stats (branch name, diff additions/deletions, commits) - const [localStats, setLocalStats] = createSignal() + const localStats = () => registry.active().localStats() - // Per-worktree apply-to-local status - const [applyStates, setApplyStates] = createSignal>({}) - const [applyTarget, setApplyTarget] = createSignal() - const [applySelectedFiles, setApplySelectedFiles] = createSignal([]) - const [applySelectionTouched, setApplySelectionTouched] = createSignal(false) + const projectLive = createProjectLive({ + ensure: (pid) => (pid ? registry.ensure(pid) : registry.active()), + active: isActivePayload, + branch: (branch) => setRepoBranch(branch), + }) const PENDING_PREFIX = "pending:" const closedDrafts = new Set() const [activePendingId, setActivePendingId] = createSignal() - // Per-sidebar-context terminal state. `terms.activeId` holds the id - // of the focused terminal tab, if any — takes precedence over - // session/pending/review when deriving the visible tab. - const terms = createTerminalState(selection) + /** Namespace key so worktree/local ids from different projects never collide. */ + const nsKey = (sel: string) => `${currentProjectId() ?? "single"}:${sel}` + + // Per-sidebar-context terminal state. `terms.activeId` holds the id of the focused + // terminal tab, if any — takes precedence over session/pending/review when deriving + // the visible tab. Contexts are project-keyed: every project reuses LOCAL and ids like "0". + const terms = createTerminalState(() => { + const sel = selection() + return sel === null ? null : nsKey(sel) + }) + + // Ambient setup reveal restores the panel after success unless the user engaged. + const ambientSetup = createAmbientSetup({ + terms, + selection: () => { + const sel = selection() + return sel === null ? null : nsKey(sel) + }, + sidePanel, + setSidePanel, + }) + const cancelAmbientSetup = ambientSetup.cancel // Inline delete confirmation: tracks which worktree is awaiting a second click/press const [pendingDelete, setPendingDelete] = createSignal(null) @@ -357,8 +401,9 @@ const AgentManagerContent: Component = () => { createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) onCleanup(() => clearTimeout(pendingDeleteTimer)) - // Per-context tab memory: maps sidebar selection key -> last active session/pending ID - const [tabMemory, setTabMemory] = createSignal>({}) + // Per-context tab memory lives in the active project's store: maps sidebar + // selection ("local" or a worktree id) -> last active session/pending ID + const tabMemory = () => registry.active().tabMemory.all() const reviewOpen = createMemo(() => { const sel = selection() @@ -391,172 +436,18 @@ const AgentManagerContent: Component = () => { setReviewCommentsByContext((prev) => ({ ...prev, [sel]: comments })) } - const applyStateForSelection = createMemo(() => { - const sel = selection() - if (!sel || sel === LOCAL) return undefined - return applyStates()[sel] + const apply = createApplyToLocal({ + vscode, + dialog, + t, + selection, + local: LOCAL, + worktrees, + diffDatas, + diffLoading, + track: metrics.track, }) - - const resolveWorktreeSessionId = (worktreeId: string) => { - const id = session.currentSessionID() - if (id) { - const current = managedSessions().find((entry) => entry.id === id) - if (current?.worktreeId === worktreeId) return id - } - return managedSessions().find((entry) => entry.worktreeId === worktreeId)?.id - } - - const applyTargetSessionId = createMemo(() => { - const target = applyTarget() - if (!target) return undefined - return resolveWorktreeSessionId(target) - }) - - const applyDiffs = createMemo(() => { - const target = applyTarget() - if (!target) return [] as WorktreeFileDiff[] - const data = diffDatas() - const current = applyTargetSessionId() - if (current && data[current]) return data[current]! - const ids = managedSessions() - .filter((entry) => entry.worktreeId === target) - .map((entry) => entry.id) - for (const id of ids) { - if (data[id]) return data[id]! - } - return [] as WorktreeFileDiff[] - }) - - const applyStateForTarget = createMemo(() => { - const target = applyTarget() - if (!target) return undefined - return applyStates()[target] - }) - - const applyBusyForTarget = createMemo(() => { - const state = applyStateForTarget() - if (!state) return false - return state.status === "checking" || state.status === "applying" - }) - - const applySelectedSet = createMemo(() => new Set(applySelectedFiles())) - - const applySelectionStats = createMemo(() => { - const set = applySelectedSet() - const selected = applyDiffs().filter((diff) => set.has(diff.file)) - const additions = selected.reduce((sum, diff) => sum + diff.additions, 0) - const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0) - return { - total: applyDiffs().length, - selected: selected.length, - additions, - deletions, - } - }) - - const applyHasSelection = createMemo(() => applySelectionStats().selected > 0) - - const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? [])) - - const applyToLocal = (worktreeId: string, selectedFiles: string[]) => { - setApplyStates((prev) => ({ - ...prev, - [worktreeId]: { - status: "checking", - message: t("agentManager.apply.checking"), - conflicts: [], - }, - })) - vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles }) - } - - const resetApplyDialog = () => { - setApplyTarget(undefined) - setApplySelectedFiles([]) - setApplySelectionTouched(false) - } - - const closeApplyDialog = () => { - resetApplyDialog() - dialog.close() - } - - const applySelectAll = () => { - setApplySelectionTouched(true) - setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) - } - - const applySelectNone = () => { - setApplySelectionTouched(true) - setApplySelectedFiles([]) - } - - const applyToggleFile = (file: string, checked: boolean) => { - setApplySelectionTouched(true) - setApplySelectedFiles((prev) => { - if (checked) { - if (prev.includes(file)) return prev - const set = new Set(prev) - set.add(file) - return applyDiffs() - .map((diff) => diff.file) - .filter((path) => set.has(path)) - } - if (!prev.includes(file)) return prev - return prev.filter((path) => path !== file) - }) - } - - const triggerApply = () => { - const target = applyTarget() - if (!target) return - if (!applyHasSelection()) return - if (applyBusyForTarget()) return - metrics.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length }) - applyToLocal(target, applySelectedFiles()) - } - - const openApplyDialog = () => { - const sel = selection() - if (!sel || sel === LOCAL) return - setApplyStates((prev) => { - if (!prev[sel]) return prev - const next = { ...prev } - delete next[sel] - return next - }) - setApplyTarget(sel) - setApplySelectionTouched(false) - setApplySelectedFiles([]) - const sid = resolveWorktreeSessionId(sel) - if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid }) - - setApplySelectedFiles(applyDiffs().map((diff) => diff.file)) - - dialog.show( - () => ( - - ), - resetApplyDialog, - ) - } + const openApplyDialog = apply.openApplyDialog const openWorktreeDirectory = () => { const sel = selection() @@ -572,47 +463,22 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel }) } - const runWorktree = (id: string) => { + const runWorktree = (id: string, destination: TerminalDestination) => { const state = runStatuses()[id]?.state ?? "idle" if (state === "running" || state === "stopping") { vscode.postMessage({ type: "agentManager.stopRunScript", worktreeId: id }) return } - vscode.postMessage({ type: "agentManager.runScript", worktreeId: id }) + vscode.postMessage(resolveRunScriptRequest(id, destination)) } const configureRunScript = () => vscode.postMessage({ type: "agentManager.configureRunScript" }) const runSelected = () => { const sel = selection() - if (sel) runWorktree(sel) + if (sel) runWorktree(sel, sideCtl.destination()) } - createEffect( - on( - () => [applyTarget(), applyDiffs(), applySelectionTouched()] as const, - ([target, diffs, touched]) => { - if (!target) return - const files = diffs.map((diff) => diff.file) - if (files.length === 0) { - if (!touched) setApplySelectedFiles([]) - return - } - - if (!touched) { - setApplySelectedFiles(files) - return - } - - const current = applySelectedFiles() - const set = new Set(current) - const next = files.filter((file) => set.has(file)) - const same = next.length === current.length && next.every((file, index) => file === current[index]) - if (!same) setApplySelectedFiles(next) - }, - ), - ) - const isPending = (id: string) => id.startsWith(PENDING_PREFIX) reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending) @@ -626,9 +492,11 @@ const AgentManagerContent: Component = () => { const releaseTabs = () => setTabWidths(false) // Tab ordering: context key → ordered session ID array (recovered from extension state) - const [worktreeTabOrder, setWorktreeTabOrder] = createSignal>({}) + const worktreeTabOrder = () => registry.active().tabOrder() + const setWorktreeTabOrder: Setter> = (v) => registry.active().setTabOrder(v) // Sidebar worktree order (persisted to extension state) - const [sidebarWorktreeOrder, setSidebarWorktreeOrder] = createSignal([]) + const sidebarWorktreeOrder = () => registry.active().worktreeOrder() + const setSidebarWorktreeOrder = (v: Parameters>[0]) => registry.active().setWorktreeOrder(v) const [draggingWorktree, setDraggingWorktree] = createSignal() const [renamingSection, setRenamingSection] = createSignal(null) let pendingNewSection = false @@ -648,7 +516,7 @@ const AgentManagerContent: Component = () => { sessions: session.sessions, managedSessions, reviewOpenByContext, - terminalIdsFor: (key) => terms.forSelection(key).map((t) => t.id), + terminalIdsFor: (key) => terms.forSelection(nsKey(key)).map((t) => t.id), }) const appendToTabOrder = tabOrderSync.append @@ -674,30 +542,23 @@ const AgentManagerContent: Component = () => { if (pending && pending === active) setActivePendingId(undefined) } - // Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs). - // Debounced to avoid serializing state on every pixel during resize drag. - let persistTimer: ReturnType | undefined - createEffect(() => { - // Read signals eagerly so Solid tracks them as dependencies - const ids = localSessionIDs().filter((id) => !isPending(id)) - const width = sidebarWidth() - clearTimeout(persistTimer) - persistTimer = setTimeout(() => { - const prev = vscode.getState>() ?? {} - vscode.setState({ ...prev, localSessionIDs: ids, sidebarWidth: width }) - }, 300) + persistLocalTabs({ + tabs: () => { + registry.version() + return Object.fromEntries(registry.all().map((store) => [store.id, store.tabs.durable(isPending)])) + }, + key: () => registry.active().id, + width: sidebarWidth, + get: () => vscode.getState>(), + set: (value) => vscode.setState(value), }) - onCleanup(() => clearTimeout(persistTimer)) // Save the currently active tab for the current sidebar context before switching away const saveTabMemory = () => { const sel = selection() if (sel === null) return - const key = sel === LOCAL ? LOCAL : sel const active = visibleTabId() - if (active) { - setTabMemory((prev) => (prev[key] === active ? prev : { ...prev, [key]: active })) - } + if (active) registry.active().tabMemory.set(sel === LOCAL ? LOCAL : sel, active) } // Invalidate local session IDs if they no longer exist (preserve pending tabs) @@ -728,14 +589,6 @@ const AgentManagerContent: Component = () => { if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setApplyStates((prev) => { - const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) - if (Object.keys(next).length === Object.keys(prev).length) return prev - return next - }) - - const target = applyTarget() - if (target && !ids.has(target)) closeApplyDialog() }) const worktreeSessionIds = createMemo( @@ -754,22 +607,21 @@ const AgentManagerContent: Component = () => { filterUnassignedSessions(session.sessions(), worktreeSessionIds(), localSet()), ) - // Local sessions (resolved from session list + pending tabs, in insertion order) - const localSessions = createMemo((): SessionInfo[] => { - const ids = localSessionIDs() - const all = session.sessions() - const lookup = new Map(all.map((s) => [s.id, s])) - const result: SessionInfo[] = [] - const now = new Date().toISOString() - for (const id of ids) { - const real = lookup.get(id) - if (real && isKnownRootSession(real)) { - result.push(real) - } else if (isPending(id)) { - result.push({ id, title: t("agentManager.session.newSession"), createdAt: now, updatedAt: now }) - } - } - return result + const projectSessionsLive = createProjectSessionsLive({ + base: projectLive.sessions, + pid: currentProjectId, + enabled: multiProject, + store: session.sessions, + managed: managedSessions, + locals: localSet, + }) + + const localSessions = createLocalSessions({ + ids: localSessionIDs, + sessions: session.sessions, + pending: isPending, + root: isKnownRootSession, + title: () => t("agentManager.session.newSession"), }) // Oldest-first sort before applyTabOrder — worktree label and tab bar must agree on "first session". @@ -819,9 +671,14 @@ const AgentManagerContent: Component = () => { return false }) + const showDetailStack = createMemo(() => showTerminalStack(history(), selection())) + const overlay = createMemo((): SetupState | null => { const state = setup() const sel = selection() + // A live Setup script terminal shows progress and failures on its own + // tab; never cover it with the blocking overlay. + if (typeof sel === "string" && sel !== LOCAL && hasSetupTerminal(nsKey(sel), terms.sides())) return null if (state.active && (!state.worktreeId || sel === state.worktreeId)) return state if (typeof sel !== "string" || sel === LOCAL) return null const busy = busyWorktrees().get(sel) @@ -834,6 +691,15 @@ const AgentManagerContent: Component = () => { } }) + /** The selected worktree is provisioning: block session CTAs, keep selection put. */ + const settingUpSelection = createMemo(() => { + const sel = selection() + if (typeof sel !== "string" || sel === LOCAL) return undefined + const busy = busyWorktrees().get(sel) + if (busy?.reason !== "setting-up") return undefined + return busy + }) + createEffect(() => { const sel = selection() if (sel === null) { @@ -987,22 +853,24 @@ const AgentManagerContent: Component = () => { if (el instanceof HTMLElement) scrollIntoView(el) } - // Navigate sidebar items with arrow keys (uses visual order from sidebarOrder) - const navigate = (direction: "up" | "down") => { - const flat = sidebarOrder() - if (flat.length === 0) return - const current = selection() ?? session.currentSessionID() - const idx = current ? flat.findIndex((f) => f.id === current) : -1 - const next = direction === "up" ? idx - 1 : idx + 1 - if (next < 0 || next >= flat.length) return - focusSidebarItem(flat[next]!) - } - - // Jump to sidebar item by 0-based index into sidebarOrder (⌘1 = index 0 = LOCAL, ⌘2 = index 1, etc.) - const jumpToItem = (index: number) => { - const item = sidebarOrder()[index] - if (item) focusSidebarItem(item) - } + // Sidebar previous/next + numeric-shortcut nav. Multi-project mode traverses + // every expanded project and atomically activates via activateSelection; + // single-project mode keeps the legacy in-process path. + const projectNav = createProjectNav( + { + multiProject, + sidebarOrder, + focus: focusSidebarItem, + projects: projectList, + states: projectStates, + sessions: projectLive.sessions, + activeProjectId, + selection, + currentSessionID: session.currentSessionID, + }, + (target) => vscode.postMessage({ type: "agentManager.activateSelection", target }), + scrollIntoView, + ) // Navigate tabs with Cmd+Alt+Left/Right const navigateTab = (direction: "left" | "right") => { @@ -1015,41 +883,27 @@ const AgentManagerContent: Component = () => { focusTab(ids[next]!) } - const selectLocal = () => { - saveTabMemory() - setReviewActive(false) - setSelection(LOCAL) - vscode.postMessage({ type: "agentManager.requestRepoInfo" }) - const locals = localSessions() - const remembered = tabMemory()[LOCAL] - if (terms.hasRemembered(LOCAL, remembered)) return termHandlers.activate(remembered!) - terms.setActiveId(undefined) - const target = remembered ? locals.find((s) => s.id === remembered) : undefined - const fallback = target ?? locals[0] - if (fallback && !isPending(fallback.id)) { - setActivePendingId(undefined) - session.selectSession(fallback.id) - } else { - setActivePendingId(fallback && isPending(fallback.id) ? fallback.id : undefined) - session.clearCurrentSession() - vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) - } - setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[LOCAL] === true) + const selectionDeps = { + saveTabMemory, + setReviewActive, + setSelection, + post: (msg: unknown) => vscode.postMessage(msg as never), + tabMemory, + terms, + activateTerminal: (id: string) => termHandlers.activate(id), + setActivePendingId, + selectSession: session.selectSession, + clearSession: session.clearCurrentSession, + resetSession: () => session.setCurrentSessionID(undefined), + isPending, + isReviewTab: (remembered: string | undefined, sel: string) => + remembered === REVIEW_TAB_ID && reviewOpenByContext()[sel] === true, } - const selectWorktree = (worktreeId: string) => { - saveTabMemory() - setSelection(worktreeId) - const remembered = tabMemory()[worktreeId] - if (terms.hasRemembered(worktreeId, remembered)) return termHandlers.activate(remembered!) - terms.setActiveId(undefined) - const rich = sessionsForWorktree(worktreeId) - const target = remembered ? rich.find((s) => s.id === remembered) : undefined - const fallback = target ?? rich[0] - if (fallback) session.selectSession(fallback.id) - else session.setCurrentSessionID(undefined) - setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true) - } + const selectLocal = () => selectLocalAction(selectionDeps, localSessions()) + + const selectWorktree = (worktreeId: string) => + selectWorktreeAction(selectionDeps, worktreeId, sessionsForWorktree(worktreeId)) const addSessionToCurrentWorktree = (sid: string) => { const sel = selection() @@ -1114,19 +968,119 @@ const AgentManagerContent: Component = () => { }) } - const syncRunStatuses = (items: RunStatus[] = []) => { - const map: Record = {} - for (const item of items) map[item.worktreeId] = item - setRunStatuses(map) + const router = createProjectStateRouter({ + catalog: projectList, + apply: (state) => applyActiveState(state), + pruneLive: (ids) => projectLive.prune(ids), + }) + + /** Store the project catalog pushed by the extension and drop states of removed projects. */ + const applyProjects = (msg: ExtensionMessage) => { + if (msg.type !== "agentManager.projects") return + const ev = msg as AgentManagerProjectsMessage + setMultiProject(ev.multiProject) + setProjectList(ev.projects) + const ids = new Set(ev.projects.map((p) => p.id)) + setProjectStates((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))) + router.routeCatalog(ev.projects) } + /** Apply one project state payload. Background payloads only feed their accordion summary. */ + const applyState = (msg: ExtensionMessage) => { + if (msg.type !== "agentManager.state") return + const state = msg as AgentManagerStateMessage + const pid = state.projectId + if (pid) setProjectStates((prev) => ({ ...prev, [pid]: state })) + const store = pid ? registry.ensure(pid) : registry.active() + // A freshly created section needs the previous list to detect the new id, + // so it is handled before the data write below replaces it. + if (pendingNewSection && isActivePayload(pid)) { + const prev = new Set(store.sections().map((s) => s.id)) + const created = (state.sections ?? []).find((s) => !prev.has(s.id)) + pendingNewSection = false + if (created) setRenamingSection(created.id) + } + // Data lands in the payload's own store unconditionally; the router + // handles only the active-transition effects (selection/tab restore). + store.applyState(state) + router.routeState(state) + } + + /** Apply the active-transition effects of a state payload (data already landed in the store). */ + const applyActiveState = (state: AgentManagerStateMessage) => { + const switched = applyProjectSwitch(state) + if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo) + if (!worktreesLoaded()) setWorktreesLoaded(true) + // When not a git repo, also mark sessions as loaded since the Kilo + // server won't connect to send the sessionsLoaded message. + if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) + if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") { + setReviewDiffStyle(state.reviewDiffStyle) + } + markdown.setRender(state.reviewMarkdownRender === true) + const current = session.currentSessionID() + if (current && !settingUpSelection()) { + const ms = state.sessions.find((s) => s.id === current) + if (ms?.worktreeId) setSelection(ms.worktreeId) + } + // Restore local session IDs from persisted state (sessions with no worktreeId) + const restored = restoreTrackedTabs( + trackedSessionInventory(state.sessions, session.sessions()), + localSessionIDs(), + state.tabOrder?.[LOCAL], + isPending, + applyTabOrder, + ) + if (restored) setLocalSessionIDs(restored) + ensurePendingTab(switched === "switched") + if (switched !== "same") { + restoreProjectTarget(state, { + selectLocal, + selectWorktree, + selectSession: session.selectSession, + focusManaged: focusManagedSession, + setSelection, + setActivePendingId, + }) + } + // Recover sidebar collapsed state and mark hydrated so transitions enable + sidebar.hydrate(state.sidebarCollapsed) + } + + /** Track project switches; full selection restore happens via restoreProjectTarget. */ + const applyProjectSwitch = (state: AgentManagerStateMessage): "first" | "switched" | "same" => { + const pid = state.projectId + const previousProject = currentProjectId() + if (pid === previousProject) return "same" + setCurrentProjectId(pid) + if (previousProject === undefined) return "first" + setReviewActive(false) + setSidePanel(null) + return "switched" + } + + /** Guarantee a fresh "New Session" tab after switching to a project with no local sessions. */ + const ensurePendingTab = (switched: boolean) => { + if (switched && localSessionIDs().length === 0) addPendingTab() + } + + createTargetRememberer({ + pid: activeProjectId, + enabled: multiProject, + applied: currentProjectId, + selection, + owns: (sel) => worktrees().some((wt) => wt.id === sel), + sessionId: session.currentSessionID, + post: vscode.postMessage, + }) + onMount(() => { const handler = (event: MessageEvent) => { const msg = event.data if (msg?.type === "navigate" && msg.view === "history") return setHistory(true) if (msg?.type !== "action") return - if (msg.action === "sessionPrevious") navigate("up") - else if (msg.action === "sessionNext") navigate("down") + if (msg.action === "sessionPrevious") projectNav.step("up") + 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 === "search") { @@ -1136,7 +1090,7 @@ const AgentManagerContent: Component = () => { requestAnimationFrame(() => sidebarSearchMenu?.open()) } } else if (msg.action === "showTerminal") { - sideCtl.openPreferred("keyboard_shortcut") + if (!sideCtl.echo()) sideCtl.openPreferred("keyboard_shortcut") } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() @@ -1161,7 +1115,7 @@ const AgentManagerContent: Component = () => { else { // Handle jumpTo1 through jumpTo9 const match = /^jumpTo([1-9])$/.exec(msg.action ?? "") - if (match) jumpToItem(parseInt(match[1]!) - 1) + if (match) projectNav.jump(parseInt(match[1]!) - 1) } } window.addEventListener("message", handler) @@ -1194,6 +1148,13 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", preventDefaults, true) + // Cmd/Ctrl+/ toggles the terminal even when VS Code's webview keybinding + // forwarding drops the key before it reaches the workbench (reported with + // the prompt input focused). When forwarding does work, the extension + // echoes the shortcut back as an action message and sideCtl dedupes it. + const shortcut = (e: KeyboardEvent) => sideCtl.press(e) + window.addEventListener("keydown", shortcut, true) + // Delete/Backspace on a selected worktree triggers inline delete confirmation. // Pressing the key twice in a row (within the 2500ms window) confirms the delete. const deleteKeyHandler = (e: KeyboardEvent) => { @@ -1207,6 +1168,18 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", deleteKeyHandler) + // Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held. + // Capture phase so the terminal's key handlers can't swallow them; blur resets state + // when the keyup is lost (e.g. Cmd+Tab away). + const modifier = isMac ? "Meta" : "Control" + const modTrack = (e: KeyboardEvent) => { + if (e.key === modifier) setHeld(e.type === "keydown") + } + const modReset = () => setHeld(false) + window.addEventListener("keydown", modTrack, true) + window.addEventListener("keyup", modTrack, true) + window.addEventListener("blur", modReset) + // When the panel regains focus (e.g. returning from terminal), focus the prompt // and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs // set pointer-events:none and overflow:hidden on body, but cleanup never runs if @@ -1262,11 +1235,10 @@ const AgentManagerContent: Component = () => { if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) if (msg.type === "agentManager.sessionClosed") handleCloseTab(msg.sessionId, false) }) - const unsubRun = vscode.onMessage((msg) => { - if (msg.type !== "agentManager.runStatus") return - const ev = msg as RunStatus - setRunStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev })) - }) + const unsubRun = vscode.onMessage((msg) => + applyRunStatus(msg, { ensure: (id) => registry.ensure(id), active: () => registry.active() }), + ) + const unsubProjects = vscode.onMessage((msg) => applyProjects(msg)) // Terminal messages have their own subscription to keep main-handler complexity in check. const terminalDispatch = createTerminalMessageHandler({ @@ -1281,9 +1253,25 @@ const AgentManagerContent: Component = () => { onSideCreated: (contextKey, terminalId) => { // Focus only when the user is still looking at this panel — // a slow create landing after a mode switch must not steal it. - if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId) + if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { + terms.requestFocus(terminalId) + } }, - onDestinationChanged: (destination) => sideCtl.setDestination(destination), + onScriptRunning: (contextKey, terminalId) => { + if (terms.sideKey() !== contextKey) return + // Setup output is informational: reveal without stealing focus, and + // remember an ambient reveal so the panel can restore itself later. + if (terms.scriptStatus(terminalId)?.kind === "setup") { + ambientSetup.reveal(contextKey, terminalId) + showSideTerminal() + terms.setSideActive(contextKey, terminalId) + return + } + showSideTerminal() + terms.setSideActive(contextKey, terminalId) + terms.requestFocus(terminalId) + }, + onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) const unsubTerminals = vscode.onMessage((msg) => { terminalDispatch(msg) @@ -1298,9 +1286,12 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.worktreeSetup") { const ev = msg as AgentManagerWorktreeSetupMessage + const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() + const updateBusy: Setter> = (value) => store.setBusy(value) if (ev.status === "ready" || ev.status === "error") { const error = ev.status === "error" - if (ev.worktreeId) setBusyWorktrees((prev) => new Map([...prev].filter(([k]) => k !== ev.worktreeId))) + if (ev.worktreeId) updateBusy((prev) => new Map([...prev].filter(([k]) => k !== ev.worktreeId))) + if (!isActivePayload(ev.projectId)) return setSetup({ active: true, message: ev.message, @@ -1319,14 +1310,17 @@ const AgentManagerContent: Component = () => { } else { // Track this worktree as setting up and auto-select it in the sidebar if (ev.worktreeId) { - setBusyWorktrees( + updateBusy( (prev) => new Map([...prev, [ev.worktreeId!, { reason: "setting-up", message: ev.message, branch: ev.branch }]]), ) + if (!isActivePayload(ev.projectId)) return setSelection(ev.worktreeId) } - // Close diff/review panels — nothing to show during setup - setSidePanel(null) + if (!isActivePayload(ev.projectId)) return + // Close diff/review panels — nothing to show during setup. + // Terminal panels keep live setup output, so they stay open. + if (sidePanel() === "diff") setSidePanel(null) setReviewActive(false) setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } @@ -1366,61 +1360,7 @@ const AgentManagerContent: Component = () => { setKb(ev.bindings) } - if (msg.type === "agentManager.state") { - const state = msg as AgentManagerStateMessage - setWorktrees(state.worktrees) - setManagedSessions(state.sessions) - setStaleWorktreeIds(new Set(state.staleWorktreeIds ?? [])) - if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo) - if (!worktreesLoaded()) setWorktreesLoaded(true) - // When not a git repo, also mark sessions as loaded since the Kilo - // server won't connect to send the sessionsLoaded message. - if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) - const prev = new Set(sections().map((s) => s.id)), - incoming = state.sections ?? [] - setSections(incoming) - if (pendingNewSection) { - pendingNewSection = false - const c = incoming.find((s) => !prev.has(s.id)) - if (c) setRenamingSection(c.id) - } - if (state.tabOrder) setWorktreeTabOrder(state.tabOrder) - if (state.worktreeOrder) setSidebarWorktreeOrder(state.worktreeOrder) - if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") { - setReviewDiffStyle(state.reviewDiffStyle) - } - markdown.setRender(state.reviewMarkdownRender === true) - if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined) - setRunScriptConfigured(state.runScriptConfigured === true) - syncRunStatuses(state.runStatuses) - const current = session.currentSessionID() - if (current) { - const ms = state.sessions.find((s) => s.id === current) - if (ms?.worktreeId) setSelection(ms.worktreeId) - } - // Restore local session IDs from persisted state (sessions with no worktreeId) - const restored = restoreTrackedTabs( - trackedSessionInventory(state.sessions, session.sessions()), - localSessionIDs(), - state.tabOrder?.[LOCAL], - isPending, - applyTabOrder, - ) - if (restored) setLocalSessionIDs(restored) - // Recover sessions collapsed state from extension-persisted state - if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed) - // Recover sidebar collapsed state and mark hydrated so transitions enable - sidebar.hydrate(state.sidebarCollapsed) - // Clear busy state for worktrees that have been removed - const ids = new Set(state.worktrees.map((wt) => wt.id)) - setBusyWorktrees((prev) => { - const next = new Map([...prev].filter(([id]) => ids.has(id))) - return next.size === prev.size ? prev : next - }) - setRunStatuses((prev) => - Object.fromEntries(Object.entries(prev).filter(([id]) => id === "local" || ids.has(id))), - ) - } + if (msg.type === "agentManager.state") applyState(msg) // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { @@ -1492,98 +1432,64 @@ const AgentManagerContent: Component = () => { } if (msg.type === "agentManager.worktreeDiff") { - const ev = msg as AgentManagerWorktreeDiffMessage - let staleFiles: Set | undefined - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] - const merged = existing - ? mergeWorktreeDiffs(existing, ev.diffs) - : { diffs: ev.diffs, stale: new Set() } - staleFiles = merged.stale - const next = merged.diffs - if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } - }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + diffs.onWorktreeDiff(msg as AgentManagerWorktreeDiffMessage) } if (msg.type === "agentManager.worktreeDiffFile") { - const ev = msg as AgentManagerWorktreeDiffFileMessage - if (ev.diff) { - setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] - const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } - }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) - return - } - setDiffFilePending(ev.sessionId, ev.file, false) + diffs.onWorktreeDiffFile(msg as AgentManagerWorktreeDiffFileMessage) } if (msg.type === "agentManager.worktreeDiffLoading") { - const ev = msg as AgentManagerWorktreeDiffLoadingMessage - setDiffLoading(ev.loading) + diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage) + } + + if (msg.type === "agentManager.worktreeDiffNotice") { + diffs.onWorktreeDiffNotice(msg as AgentManagerWorktreeDiffNoticeMessage) + } + + if (msg.type === "agentManager.diffBranches") { + review.onBranches(msg as AgentManagerDiffBranchesMessage) } if (msg.type === "agentManager.applyWorktreeDiffResult") { - const ev = msg as AgentManagerApplyWorktreeDiffResultMessage - const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size - const count = ev.conflicts?.length ?? 0 - setApplyStates((prev) => ({ - ...prev, - [ev.worktreeId]: { - status: ev.status, - message: ev.message, - conflicts: ev.conflicts ?? [], - }, - })) - - if (ev.status === "success") { - showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message }) - if (applyTarget() === ev.worktreeId) closeApplyDialog() - } - if (ev.status === "conflict") { - const summary = - count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message - showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary }) - } - if (ev.status === "error") { - showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message }) - } + apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage) } if (msg.type === "agentManager.revertWorktreeFileResult") revertCtl.onResult(msg as never) - if (msg.type === "agentManager.worktreeStats") { - const ev = msg as AgentManagerWorktreeStatsMessage - const map: Record = {} - for (const s of ev.stats) map[s.worktreeId] = s - setWorktreeStats(map) - } + applyProjectSelection(msg, { + // The catalog push is synchronous, so activeProjectId is current when + // the ack arrives; currentProjectId may still be catching up after an + // async project reactivation, so it must not be part of the guard. + active: (projectId) => activeProjectId() === projectId, + managed: (projectId) => projectLive.sessions()[projectId] ?? projectStates()[projectId]?.sessions ?? [], + local: () => selectLocal(), + // The active guard above already scopes by project; apply the worktree + // optimistically and let the arriving state reconcile. + worktree: (projectId, worktreeId) => selectWorktree(worktreeId), + session: session.selectSession, + openTab: (id) => placeLocal(id, undefined, undefined), + managedSession: focusManagedSession, + }) - if (msg.type === "agentManager.localStats") { - const ev = msg as AgentManagerLocalStatsMessage - setLocalStats(ev.stats) - setRepoBranch(ev.stats.branch) - } - - if (msg.type === "agentManager.prStatus") { - const ev = msg as AgentManagerPRStatusMessage - setPrStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev.pr })) - } + if (projectLive.apply(msg)) return }) onCleanup(() => { window.removeEventListener("message", handler) window.removeEventListener("keydown", preventDefaults, true) + window.removeEventListener("keydown", shortcut, true) window.removeEventListener("keydown", deleteKeyHandler) + window.removeEventListener("keydown", modTrack, true) + window.removeEventListener("keyup", modTrack, true) + window.removeEventListener("blur", modReset) window.removeEventListener("focus", onWindowFocus) window.removeEventListener("newTaskRequest", newTaskHandler, true) drafts.cleanup() unsubCreate() unsubSessions() unsubRun() + unsubProjects() unsubTerminals() unsub() }) @@ -1595,41 +1501,74 @@ const AgentManagerContent: Component = () => { // Request worktree/session state from extension — handles race where // initializeState() pushState fires before the webview is mounted vscode.postMessage({ type: "agentManager.requestState" }) + // Same race for the project catalog pushed at panel attach + vscode.postMessage({ type: "agentManager.requestProjects" }) // Open a pending "New Session" tab if there are no persisted local sessions if (localSessionIDs().length === 0) { addPendingTab() } }) - const selectedDiffSessionId = () => { - const sel = selection() - if (sel === LOCAL) return LOCAL - if (!sel) return undefined + // Diff context = sidebar selection (worktree id or LOCAL), stable across + // session tab switches inside the context so the git scopes don't refetch. + const diffCtx = createMemo(() => selection() ?? undefined) + // Active session within the diff context. The Session scope follows it, so + // switching session tabs swaps only the session diff. + const activeDiffSession = createMemo(() => { + const sel = selection() + if (!sel) return undefined const current = session.currentSessionID() + if (sel === LOCAL) { + if (current && localSessionIDs().includes(current) && !isPending(current)) return current + return localSessionIDs().find((id) => !isPending(id)) + } if (current) { const item = managedSessions().find((entry) => entry.id === current) if (item?.worktreeId === sel) return current } - return managedSessions().find((entry) => entry.worktreeId === sel)?.id - } + }) - const currentDiffSessionId = createMemo(selectedDiffSessionId) + // Diff scope + base branch state, shared by the side panel and review tab. + const review = createDiffReviewScope({ + ctx: diffCtx, + session: activeDiffSession, + panelOpen: diffOpen, + reviewActive, + vscode, + }) + // The composite id (ctx#scope) the extension keys diff data by. + const diffScopeId = review.id - // Start/stop diff watch when panel opens/closes, review tab opens, or session changes + // Shared scope + base-picker controls for the side panel and review tab. + const diffScopeControls = (compact: boolean) => ( + + ) + + // Start/stop diff watch when the panel opens/closes, the review tab opens, + // or the composite id (context, scope, active session) changes. createEffect(() => { const panel = diffOpen() - const review = reviewActive() + const active = reviewActive() + const id = review.id() - if (panel || review) { - const id = currentDiffSessionId() - if (id) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) - return - } - vscode.postMessage({ type: "agentManager.stopDiffWatch" }) - setDiffLoading(false) + if ((panel || active) && id) { + vscode.postMessage({ type: "agentManager.startDiffWatch", ...wireDiffId(id) }) return } @@ -1670,32 +1609,24 @@ const AgentManagerContent: Component = () => { tabFocus.restore() } - // Data for the review tab: use local diff data for local context, - // current session for selected worktree context, or first available in that worktree. + // Data for the review tab / side panel: keyed by the composite diff id + // (ctx#scope) the extension pushes, so each scope keeps its own file set and + // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() - const sel = selection() - const id = session.currentSessionID() - if (sel === LOCAL) return data[LOCAL] ?? [] - if (id && data[id]) { - const current = managedSessions().find((s) => s.id === id) - if (sel && current?.worktreeId === sel) return data[id]! - } - if (!sel) return [] - const ids = managedSessions() - .filter((s) => s.worktreeId === sel) - .map((s) => s.id) - for (const sid of ids) { - if (data[sid]) return data[sid]! - } - return [] + const key = diffScopeId() + if (!key) return [] + return data[key] ?? [] }) - const diffSessionKey = createMemo(() => { - const sel = selection() - if (sel === LOCAL) return `local:${LOCAL}` - if (sel === null) return `session:${session.currentSessionID() ?? ""}` - return `worktree:${sel}` + const diffSessionKey = createMemo(() => diffScopeId() ?? "") + + // Source-level notice for the active composite id (e.g. snapshots disabled + // for the Session scope), shown as a banner instead of the empty state. + const diffNotice = createMemo(() => { + const key = diffScopeId() + if (!key) return undefined + return diffNotices()[key] }) const setSharedDiffStyle = (style: "unified" | "split") => { @@ -1704,56 +1635,15 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style }) } - const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { - setDiffFileLoading((prev) => { - const session = prev[sessionId] ?? {} - if (value) { - if (session[file]) return prev - return { - ...prev, - [sessionId]: { ...session, [file]: true }, - } - } - - if (!session[file]) return prev - const next = { ...session } - delete next[file] - if (Object.keys(next).length === 0) { - const result = { ...prev } - delete result[sessionId] - return result - } - return { - ...prev, - [sessionId]: next, - } - }) - } - const requestDiffFile = (file: string) => { - const sessionId = currentDiffSessionId() - if (!sessionId) return - if (diffFileLoading()[sessionId]?.[file]) return - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) + const id = diffScopeId() + if (!id) return + diffs.requestDiffFile(id, file) } - const refreshStaleDiffs = (sessionId: string, files: Set) => { - const loading = diffFileLoading()[sessionId] ?? {} - for (const file of files) { - if (loading[file]) continue - setDiffFilePending(sessionId, file, true) - vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file }) - } - } + const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) - const diffFileLoadingForCurrent = createMemo(() => { - const sessionId = currentDiffSessionId() - if (!sessionId) return new Set() - return new Set(Object.keys(diffFileLoading()[sessionId] ?? {})) - }) - - const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t) + const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t) const handleConfigureSetupScript = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) @@ -2010,6 +1900,8 @@ const AgentManagerContent: Component = () => { const handleAddSession = () => { const sel = selection() + // Setup is still provisioning this worktree; the Setup tab shows progress. + if (settingUpSelection()) return expandSidebar() if (sel === LOCAL) return addPendingTab() if (sel) { @@ -2092,9 +1984,6 @@ const AgentManagerContent: Component = () => { postMessage: (msg) => vscode.postMessage(msg as never), onRemove: freezeTabs, onShowSide: showSideTerminal, - onHideSide: () => { - if (sidePanel() === "terminal") setSidePanel(null) - }, getSelection: selection, LOCAL, REVIEW_TAB_ID, @@ -2102,18 +1991,28 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, - visible: () => sidePanel() === "terminal", - focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id, - hide: () => setSidePanel(null), + visible: () => sidePanel() === "terminal" && !history() && !reviewActive(), + focusedId: () => terms.sideFocusedId(), + hide: () => { + cancelAmbientSetup() + setSidePanel(null) + }, refocus: () => window.dispatchEvent(new Event("focusPrompt")), postMessage: (msg) => vscode.postMessage(msg as never), track: (button, surface, properties) => metrics.track(button, surface, properties), - openVscode: () => { - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) - }, + // Panel-local pick, immune to cross-window setting echoes (see side.ts). + saved: readSavedDestination(vscode.getState>()), + save: (d) => vscode.setState({ ...vscode.getState>(), terminalDestination: d }), + openVscode: () => + vscode.postMessage( + resolveVscodeTerminalRequest( + selection(), + session.currentSessionID(), + (wt) => managedSessions().find((ms) => ms.worktreeId === wt)?.id, + ) as never, + ), }) + createEffect(on(terms.sideKey, (key, previous) => sideCtl.syncContext(key, previous), { defer: true })) const handleReviewTabMouseDown = (e: MouseEvent) => { if (e.button !== 1) return @@ -2186,8 +2085,8 @@ const AgentManagerContent: Component = () => { if (!id) return undefined if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") } if (isTerminalTabId(id)) { - const term = terms.lookup().get(id) - return term ? { id, title: term.title } : undefined + const title = terms.title(id) + return title ? { id, title } : undefined } return activeTabs().find((s) => s.id === id) }) @@ -2215,8 +2114,8 @@ const AgentManagerContent: Component = () => { 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. - if (sidePanel() === "terminal" && terms.focusedId() && terms.focusedId() === terms.side()?.id) { + // surprising. Only that terminal dies; the panel keeps the rest. + if (sidePanel() === "terminal" && terms.sideFocusedId()) { if (sideCtl.close()) return } if (termHandlers.closeActive()) { @@ -2261,6 +2160,61 @@ const AgentManagerContent: Component = () => { confirmDeleteWorktree(sel) } + /** The Local/worktrees/sessions body of the active project. */ + const toggleDiffPanel = () => { + metrics.track("side_review", "tab_toolbar", { + action: diffOpen() && !reviewActive() ? "close" : "open", + }) + if (reviewActive()) { + closeReviewTab() + setSidePanel("diff") + return + } + setSidePanel((prev) => (prev === "diff" ? null : "diff")) + } + + const renderTabById = (id: string) => + renderTab(id, { + terms, + REVIEW_TAB_ID, + tabIds, + kb, + reviewActive, + currentSessionID: () => session.currentSessionID(), + activePendingId, + visibleTabId, + isPending, + isBusy: isSessionBusy, + tabLookup, + adjacentHint, + activateTerminal: termHandlers.activate, + deactivateTerminal: termHandlers.deactivate, + closeTerminal: (id) => tabFocus.run(() => termHandlers.closeTerminal(id)), + terminalMiddleClick: (id, event) => tabFocus.middle(event, () => termHandlers.middleClick(id, event)), + closeReview: closeReviewTab, + reviewMiddleClick: handleReviewTabMouseDown, + selectReviewTab: () => setReviewActive(true), + selectSessionTab, + sessionMiddleClick: handleTabMouseDown, + sessionClose: handleCloseTab, + sessionFork: handleForkSession, + onTabKey: tabFocus.key, + reviewLabel: t("session.tab.review"), + reviewTooltip: t("command.review.toggle"), + }) + + const renderAddTab = () => + renderNewTabButton({ + contextSelected: () => selection() !== null, + kb, + newSessionLabel: t("agentManager.session.new"), + newTerminalLabel: t("agentManager.terminal.new"), + newSessionMenuLabel: t("agentManager.session.newSession"), + moreOptionsLabel: t("agentManager.tab.newOptions"), + onNewSession: metrics.click("new_session", "tab_bar", handleAddSession), + onNewTerminal: metrics.click("embedded_terminal", "new_tab_menu", () => termHandlers.requestNew()), + }) + return (
{ >
@@ -2288,616 +2242,132 @@ const AgentManagerContent: Component = () => { } }} /> - {/* Local repo item */} - - - {/* WORKTREES section */} -
-
- - (sidebarSearchMenu = value)} - onSelect={focusSidebarSearchItem} - onCreate={createWorktree} - onNew={showNewWorktreeDialog} - onSection={newSection} - onShortcuts={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)} - onSetup={setupScript} - onBranch={handleChangeDefaultBaseBranch} - /> -
-
- -
-
-
-
-
- } - > - -
- - {t("agentManager.notGitRepo")} -
-
- - {(() => { - const [renamingWt, setRenamingWt] = createSignal(null) - const [renameValue, setRenameValue] = createSignal("") - - const startRename = (wtId: string, current: string) => { - setRenamingWt(wtId) - setRenameValue(current) - } - - let cancelled = false - - const commitRename = (wtId: string) => { - if (cancelled) { - cancelled = false - return - } - const value = renameValue().trim() - setRenamingWt(null) - if (!value) return - vscode.postMessage({ type: "agentManager.renameWorktree", worktreeId: wtId, label: value }) - } - - const cancelRename = () => { - cancelled = true - setRenamingWt(null) - } - - const hasSections = createMemo(() => sections().length > 0) - const wtIds = createMemo(() => sortedWorktrees().map((wt) => wt.id)) - const secIds = createMemo(() => new Set(sections().map((s) => s.id))) - const home = () => new Map(sortedWorktrees().map((w) => [w.id, w.sectionId] as const)) - const sectionAware = sectionAwareDetector(secIds, home) - - const onWtDragStart = (event: DragEvent) => { - const id = event.draggable?.id - if (typeof id === "string") setDraggingWorktree(id) - document.body.classList.add("am-wt-dragging-active") - } - const onWtDragOver = (event: DragEvent) => { - const from = event.draggable?.id - const to = event.droppable?.id - if (typeof from !== "string" || typeof to !== "string") return - if (secIds().has(to)) return - setSidebarWorktreeOrder((prev) => { - const cur = applyTabOrder( - sortedWorktrees().map((w) => ({ id: w.id })), - prev, - ).map((i) => i.id) - return reorderTabs(cur, from, to) ?? prev - }) - } - const onWtDragEnd = (event: DragEvent) => { - const from = event.draggable?.id - const to = event.droppable?.id - setDraggingWorktree(undefined) - document.body.classList.remove("am-wt-dragging-active") - if (typeof from === "string" && typeof to === "string" && secIds().has(to)) { - moveToSection([from], to) - return - } - vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: sidebarWorktreeOrder() }) - } - - return ( - - - - - {(() => { - const renderWt = (wt: WorktreeState, idx: () => number, list?: WorktreeState[]) => { - const wtSessions = createMemo(() => - managedSessions().filter((ms) => ms.worktreeId === wt.id), - ) - const navHint = () => - adjacentHint( - wt.id, - selection() ?? session.currentSessionID() ?? "", - sidebarOrder().map((f) => f.id), - kb().previousSession ?? "", - kb().nextSession ?? "", - ) - const groupSize = () => - !wt.groupId ? 0 : sortedWorktrees().filter((w) => w.groupId === wt.groupId).length - const sortable = createSortable(wt.id) - void sortable - return ( -
- - vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }), - )} - sections={sections()} - currentSectionId={wt.sectionId} - onMoveToSection={(secId) => moveToSection([wt.id], secId)} - onMoveToNewSection={metrics.click("new_section", "worktree_menu", () => newSection())} - onClick={() => { - if (pendingDelete() === wt.id) { - confirmDeleteWorktree(wt.id) - return - } - selectWorktree(wt.id) - }} - onDelete={(e) => handleDeleteWorktree(wt.id, e)} - onStartRename={(current) => startRename(wt.id, current)} - onRenameInput={(v) => setRenameValue(v)} - onCommitRename={() => commitRename(wt.id)} - onCancelRename={cancelRename} - onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)} - onCopyPath={() => navigator.clipboard.writeText(wt.path)} - onOpen={metrics.click("open_worktree_window", "worktree_menu", () => - vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }), - )} - /> -
- ) - } - if (hasSections()) { - const post = vscode.postMessage.bind(vscode) - return ( - - {(item, idx) => { - if (item.kind === "section") { - const sec = item.section - const members = createMemo(() => worktreesInSection(sec.id)) - return ( - setRenamingSection(null)} - onToggle={() => - post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id }) - } - onRename={(name) => - post({ type: "agentManager.renameSection", sectionId: sec.id, name }) - } - onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })} - onSetColor={(color) => - post({ type: "agentManager.setSectionColor", sectionId: sec.id, color }) - } - isFirst={idx() === 0} - isLast={idx() === topLevelItems().length - 1} - onMoveUp={() => moveSection(sec.id, -1)} - onMoveDown={() => moveSection(sec.id, 1)} - > - -
- {(wt, wtIdx) => renderWt(wt, wtIdx, members())} -
-
-
- ) - } - const ug = ungrouped() - const wtIdx = () => ug.indexOf(item.wt) - return renderWt(item.wt, wtIdx, ug) - }} -
- ) - } - return {(wt, idx) => renderWt(wt, idx)} - })()} -
- - {(() => { - const wt = sortedWorktrees().find((w) => w.id === draggingWorktree()) - if (!wt) return null - return ( -
- - {worktreeLabel(wt)} -
- ) - })()} -
-
- ) - })()} - - - -
- -
-
- - (selection() === null ? session.currentSessionID() : undefined)} - onToggle={toggleSessions} - onSelect={selectUnassigned} - onPromote={promoteSession} - onOpen={openUnassigned} - /> + + (sidebarSearchMenu = ref)} + onShortcuts={handleShowKeyboardShortcuts} + /> + + + (sidebarSearchMenu = ref)} + onSearchSelect={focusSidebarSearchItem} + onCreateWorktree={createWorktree} + onNewWorktree={showNewWorktreeDialog} + onNewSection={newSection} + onShortcuts={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)} + onSetup={setupScript} + onBranch={handleChangeDefaultBaseBranch} + sections={sections} + sortedWorktrees={sortedWorktrees} + worktrees={worktrees} + ungrouped={ungrouped} + topLevelItems={topLevelItems} + worktreesInSection={worktreesInSection} + sidebarOrder={sidebarOrder} + sidebarWorktreeOrder={sidebarWorktreeOrder} + setSidebarWorktreeOrder={setSidebarWorktreeOrder} + draggingWorktree={draggingWorktree} + setDraggingWorktree={setDraggingWorktree} + moveToSection={moveToSection} + moveSection={moveSection} + renamingSection={renamingSection} + setRenamingSection={setRenamingSection} + managedSessions={managedSessions} + worktreeLabel={worktreeLabel} + worktreeSubtitle={worktreeSubtitle} + pendingDelete={pendingDelete} + busy={(id) => busyWorktrees().has(id)} + isAgentBusy={isAgentBusy} + isStaleWorktree={isStaleWorktree} + shortcutMap={shortcutMap} + worktreeStats={worktreeStats} + prStatuses={prStatuses} + runStatuses={runStatuses} + confirmDeleteWorktree={confirmDeleteWorktree} + handleDeleteWorktree={handleDeleteWorktree} + confirmRemoveStaleWorktree={confirmRemoveStaleWorktree} + unassignedSessions={unassignedSessions} + selectUnassigned={selectUnassigned} + promoteSession={promoteSession} + openUnassigned={openUnassigned} + track={metrics.click} + /> +
{/* Tab bar — full version with tabs renders when a section is selected and has tabs; otherwise a minimal version still renders so the sidebar toggle button stays at a fixed position. */} - -
- -
-
- } - > - - - -
-
- -
-
-
-
-
- - - {(id) => - renderTab(id, { - terms, - REVIEW_TAB_ID, - tabIds, - kb, - reviewActive, - currentSessionID: () => session.currentSessionID(), - activePendingId, - visibleTabId, - isPending, - isBusy: isSessionBusy, - tabLookup, - adjacentHint, - activateTerminal: termHandlers.activate, - deactivateTerminal: termHandlers.deactivate, - closeTerminal: (id) => tabFocus.run(() => termHandlers.closeTerminal(id)), - terminalMiddleClick: (id, event) => - tabFocus.middle(event, () => termHandlers.middleClick(id, event)), - closeReview: closeReviewTab, - reviewMiddleClick: handleReviewTabMouseDown, - selectReviewTab: () => setReviewActive(true), - selectSessionTab, - sessionMiddleClick: handleTabMouseDown, - sessionClose: handleCloseTab, - sessionFork: handleForkSession, - onTabKey: tabFocus.key, - reviewLabel: t("session.tab.review"), - reviewTooltip: t("command.review.toggle"), - }) - } - - -
-
-
-
- -
-
- {renderNewTabButton({ - contextSelected: () => selection() !== null, - kb, - newSessionLabel: t("agentManager.session.new"), - newTerminalLabel: t("agentManager.terminal.new"), - newSessionMenuLabel: t("agentManager.session.newSession"), - moreOptionsLabel: t("agentManager.tab.newOptions"), - onNewSession: metrics.click("new_session", "tab_bar", handleAddSession), - onNewTerminal: metrics.click("embedded_terminal", "new_tab_menu", () => termHandlers.requestNew()), - })} -
- -
- {(() => { - const sel = () => selection() - const isWorktree = () => typeof sel() === "string" && sel() !== LOCAL - const stats = () => { - if (sel() === LOCAL) return localStats() - return typeof sel() === "string" ? worktreeStats()[sel() as string] : undefined - } - const hasChanges = () => { - const s = stats() - return s && (s.files > 0 || s.additions > 0 || s.deletions > 0) - } - const applyBusy = () => { - const state = applyStateForSelection() - if (!state) return false - return state.status === "checking" || state.status === "applying" - } - return ( - <> - - <> - - - - - - - - - - {(() => { - const rid = () => (sel() === LOCAL ? LOCAL : (sel() as string)) - const rs = () => runStatuses()[rid()] - const active = () => rs()?.state === "running" || rs()?.state === "stopping" - const configured = runScriptConfigured - const title = () => (configured() ? (active() ? "Stop" : "Run") : "Configure run script") - return ( - - - - - - ) => ( - - )} - /> - - - - - {t("agentManager.run.configure")} - - - - - - ) - })()} - - - - - - ) - })()} - - - - - - {/* 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. */} - sidePanel() === "terminal"} - keybind={() => kb().showTerminal ?? ""} - onOpen={() => sideCtl.openPreferred("tab_toolbar")} - onChoose={sideCtl.choose} - /> -
-
- - - {(tab) => ( -
- {tab().title || t("agentManager.session.untitled")} -
- )} -
-
- -
+ runWorktree(id, sideCtl.destination())} + onConfigureRun={configureRunScript} + diffOpen={diffOpen} + reviewActive={reviewActive} + onToggleDiff={toggleDiffPanel} + onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)} + terminalDestination={sideCtl.destination} + terminalDestinationActive={() => sidePanel() === "terminal"} + terminalKeybind={() => kb().showTerminal ?? ""} + onTerminalDestinationOpen={() => { + cancelAmbientSetup() + sideCtl.openPreferred("tab_toolbar") + }} + onTerminalDestinationChoose={sideCtl.choose} + track={metrics.click} + /> {/* Empty worktree state */} @@ -2960,7 +2430,7 @@ const AgentManagerContent: Component = () => { worktreeSessionIds={activeWorktreeSessionIds} /> - + {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
{/* Chat/terminal + side diff panel. Keep it mounted under the @@ -2971,68 +2441,97 @@ const AgentManagerContent: Component = () => {
{/* Keep terminal tabs mounted so output streams across worktree switches. */} {renderTerminalLayer({ state: terms })} -
- { - if (addSessionToCurrentWorktree(id)) return - if (localSessionIDs().includes(id)) { - session.selectSession(id) - if (selection() === null) setSelection(LOCAL) - return + {/* 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. */} + +
+ + +
+ {settingUpSelection()?.message ?? t("agentManager.setup.settingUp")} +
+ } - // Navigate to owning worktree instead of forcing into local mode - if (worktreeSessionIds().has(id)) { - const ms = managedSessions().find((s) => s.id === id) - if (ms?.worktreeId) { - selectWorktree(ms.worktreeId) + > +
+ +
+
{t("agentManager.session.noSessions")}
+ +
+
+
+ +
+ { + if (addSessionToCurrentWorktree(id)) return + if (localSessionIDs().includes(id)) { session.selectSession(id) - setReviewActive(false) + if (selection() === null) setSelection(LOCAL) return } - } - openLocally(id) - }} - onShowHistory={() => setHistory(true)} - onForkMessage={readOnly() ? undefined : handleForkSession} - onForkSession={readOnly() ? undefined : handleForkSession} - readonly={readOnly()} - continueInWorktree={selection() === LOCAL} - promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} - pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} - /> - -
- - {t("agentManager.session.readonly")} - - -
-
-
+ // Navigate to owning worktree instead of forcing into local mode + if (worktreeSessionIds().has(id)) { + const ms = managedSessions().find((s) => s.id === id) + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) + session.selectSession(id) + setReviewActive(false) + return + } + } + openLocally(id) + }} + onShowHistory={() => setHistory(true)} + onForkMessage={readOnly() ? undefined : handleForkSession} + onForkSession={readOnly() ? undefined : handleForkSession} + readonly={readOnly()} + continueInWorktree={selection() === LOCAL} + promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} + pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} + /> + +
+ + {t("agentManager.session.readonly")} + + +
+
+
+
{/* One inspector host for all right-side modes. It stays mounted while a side terminal is alive — hidden via @@ -3060,8 +2559,11 @@ const AgentManagerContent: Component = () => { diffs={reviewDiffs()} loading={diffLoading()} loadingFiles={diffFileLoadingForCurrent()} - sessionId={currentDiffSessionId()} + sessionId={activeDiffSession()} sessionKey={diffSessionKey()} + notice={diffNotice()} + lead={diffScopeControls(true)} + canRevert={scopeCapabilities(review.scope()).revert} diffStyle={reviewDiffStyle()} onDiffStyleChange={setSharedDiffStyle} markdownRender={markdown.render()} @@ -3078,10 +2580,9 @@ const AgentManagerContent: Component = () => { } onRequestDiff={requestDiffFile} onOpenFile={(file, line) => { - const id = currentDiffSessionId() + const id = diffCtx() if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line }) - else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line }) }} onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)} revertingFiles={revertCtl.reverting()} @@ -3092,8 +2593,23 @@ const AgentManagerContent: Component = () => { state={terms} contextKey={terms.sideKey} visible={() => sidePanel() === "terminal"} - onClose={() => sideCtl.close()} - onStart={() => termHandlers.requestSide()} + onSelect={(id) => termHandlers.selectSide(id)} + onClose={(id) => { + cancelAmbientSetup() + termHandlers.closeSide(id) + }} + onCloseOthers={(id) => { + cancelAmbientSetup() + termHandlers.closeSideOthers(id) + }} + onStart={() => { + cancelAmbientSetup() + termHandlers.addSide() + }} + onStop={(id) => { + cancelAmbientSetup() + termHandlers.stopSide(id) + }} />
@@ -3106,8 +2622,12 @@ const AgentManagerContent: Component = () => { diffs={reviewDiffs()} loading={diffLoading()} loadingFiles={diffFileLoadingForCurrent()} - sessionId={currentDiffSessionId()} + sessionId={activeDiffSession()} sessionKey={diffSessionKey()} + notice={diffNotice()} + lead={diffScopeControls(false)} + canRevert={scopeCapabilities(review.scope()).revert} + canComment={scopeCapabilities(review.scope()).comments} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} composer={reviewComposer} @@ -3119,9 +2639,8 @@ const AgentManagerContent: Component = () => { onMarkdownRenderChange={markdown.update} onRequestDiff={requestDiffFile} onOpenFile={(file, line) => { - const id = currentDiffSessionId() + const id = diffCtx() if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line }) - else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line }) }} onRevertFile={metrics.use("revert_file", "fullscreen_review", revertCtl.revert)} revertingFiles={revertCtl.reverting()} @@ -3139,53 +2658,16 @@ const AgentManagerContent: Component = () => { export const AgentManagerApp: Component = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 1d5750caa4..083d892d55 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,4 @@ -import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js" +import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js" import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" @@ -7,7 +7,6 @@ import { FileIcon } from "@kilocode/kilo-ui/file-icon" import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" -import { RadioGroup } from "@kilocode/kilo-ui/radio-group" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" @@ -15,6 +14,7 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" +import { DiffStyleSelect } from "../diff-viewer/InlineSelect" import { useVSCode } from "../src/context/vscode" import { useServer } from "../src/context/server" import { useProvider } from "../src/context/provider" @@ -64,12 +64,19 @@ import { createDiffRequests } from "../diff-viewer/diff-requests" // --- Data model --- +/** Well-known diff source notices → i18n keys (mirrors the standalone viewer). */ +const DIFF_NOTICE_KEYS: Record = { + "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", +} + interface DiffPanelProps { diffs: WorktreeFileDiff[] loading: boolean loadingFiles?: Set sessionId?: string sessionKey?: string + /** Well-known source notice kind (e.g. "snapshots-disabled"), shown as a banner. */ + notice?: string diffStyle?: "unified" | "split" onDiffStyleChange?: (style: "unified" | "split") => void markdownRender?: boolean @@ -86,10 +93,19 @@ interface DiffPanelProps { onRevertFile?: (file: string) => void revertingFiles?: Set activeTerminalId?: string + /** Optional leading row rendered under the header (e.g. the scope selector). */ + lead?: JSXElement + /** Defaults to true. Hides the per-file Revert action when false. */ + canRevert?: boolean } export const DiffPanel: Component = (props) => { const { t } = useLanguage() + const noticeText = () => { + const n = props.notice + if (!n) return "" + return t(DIFF_NOTICE_KEYS[n] ?? n) + } const vscode = useVSCode() const server = useServer() const provider = useProvider() @@ -475,21 +491,18 @@ export const DiffPanel: Component = (props) => {
- {t("session.review.change.other")} + {/* Scope + base picker replace the static "Changes" title: it names + what you're looking at and is the primary control. Always shown, + so an empty scope can still be switched away from. */} + {props.lead} 0}> <> - style} - label={(style) => - style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split") - } - onSelect={(style) => { - if (!style) return - props.onDiffStyleChange?.(style) - }} + props.onDiffStyleChange?.(style)} + unifiedLabel={t("ui.sessionReview.diffStyle.unified")} + splitLabel={t("ui.sessionReview.diffStyle.split")} + title={t("ui.sessionReview.diffStyle.unified")} /> {t("session.review.filesChanged", { count: totals().files })} @@ -536,6 +549,15 @@ export const DiffPanel: Component = (props) => {
+ +
+ + + + {noticeText()} +
+
+
@@ -543,7 +565,7 @@ export const DiffPanel: Component = (props) => {
- +
{t("session.review.noChanges")}
@@ -636,7 +658,7 @@ export const DiffPanel: Component = (props) => { />
- + void; defaultBaseBranch?: string }> = (props) => { +export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBranch?: string; projectId?: string }> = ( + props, +) => { const { t } = useLanguage() const vscode = useVSCode() const server = useServer() @@ -250,7 +252,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran onMount(() => { setBranchesLoading(true) - vscode.postMessage({ type: "agentManager.requestBranches" }) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId }) // Resize textarea if restoring a cached prompt if (prompt()) adjustHeight() const focus = () => { @@ -302,6 +304,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran vscode.postMessage({ type: "agentManager.createMultiVersion", + projectId: props.projectId, text, name: name().trim() || undefined, versions: count, @@ -470,7 +473,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran const url = prUrl().trim() if (!url || isPending()) return setPrPending(true) - vscode.postMessage({ type: "agentManager.importFromPR", url }) + vscode.postMessage({ type: "agentManager.importFromPR", projectId: props.projectId, url }) } const handleBranchSelect = (name: string) => { @@ -479,7 +482,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran setImportPending(true) setBranchOpen(false) setBranchSearch("") - vscode.postMessage({ type: "agentManager.importFromBranch", branch: name }) + vscode.postMessage({ type: "agentManager.importFromBranch", projectId: props.projectId, branch: name }) } return ( diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectActions.tsx new file mode 100644 index 0000000000..80d44c36be --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectActions.tsx @@ -0,0 +1,96 @@ +/** @jsxImportSource solid-js */ + +import type { Component } from "solid-js" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import type { LanguageContextValue } from "../src/context/language" +import { parseBindingTokens } from "./keybind-tokens" + +interface Props { + branch: string + bindings: Record + loaded: boolean + t: LanguageContextValue["t"] + onCreate: () => void + onNew: () => void + onSection: () => void + onSetup: () => void + onBranch: () => void +} + +export const ProjectActions: Component = (props) => ( +
+
+ + + + + + + + + + + + + + + {props.t("agentManager.worktree.newSection")} + + + + +
+ + + + + + {props.t("agentManager.worktree.setupScript")} + + + + + {props.t("agentManager.worktree.defaultBaseBranch")}: {props.branch} + + + + + +
+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectBranchDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectBranchDialog.tsx new file mode 100644 index 0000000000..423172f237 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectBranchDialog.tsx @@ -0,0 +1,73 @@ +/** @jsxImportSource solid-js */ + +import { createMemo, createSignal, onCleanup, onMount, 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 { + projectId: string + selected?: string + detected?: string + onClose: () => void +} + +export const ProjectBranchDialog: Component = (props) => { + const { t } = useLanguage() + const vscode = useVSCode() + const [search, setSearch] = createSignal("") + const [branches, setBranches] = createSignal([]) + 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", projectId: props.projectId, branch }) + props.onClose() + } + const unsubscribe = vscode.onMessage((message) => { + if (message.type !== "agentManager.branches") return + const event = message as AgentManagerBranchesMessage + setBranches(event.branches) + setLoading(false) + }) + onCleanup(unsubscribe) + onMount(() => vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId })) + + return ( + +
+ { + setSearch(value) + setHighlighted(-1) + }} + onSelect={(branch) => select(branch.name)} + 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 ?? props.detected} + autoOption={{ + label: t("agentManager.worktree.defaultBaseBranchAuto"), + hint: props.detected, + active: !props.selected, + highlighted: highlighted() === -1, + onSelect: () => select(), + }} + /> +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx new file mode 100644 index 0000000000..fa03d3e0c4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -0,0 +1,225 @@ +import { createMemo, type Component } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import type { + AgentManagerStateMessage, + AgentProjectSnapshot, + LocalGitStats, + PRStatus, + ProjectSessionInfo, + WorktreeGitStats, +} from "../src/types/messages" +import type { LanguageContextValue } from "../src/context/language" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useVSCode } from "../src/context/vscode" +import { ProjectsSection } from "./ProjectsSection" +import { ProjectSidebarBody } from "./ProjectSidebarBody" +import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" +import type { SidebarSearchItem } from "./sidebar-search" +import { LOCAL } from "./navigate" +import { NewWorktreeDialog } from "./NewWorktreeDialog" +import { ProjectBranchDialog } from "./ProjectBranchDialog" + +interface Props { + projects: AgentProjectSnapshot[] + states: Record + stats: Record> + local: Record + prs: Record> + sessions: Record + selectedProject?: string + selection?: string + currentSessionID?: () => string | undefined + busy?: (id: string) => boolean + bindings: Record + t: LanguageContextValue["t"] + onSearchRef: (ref: SidebarSearchMenuRef) => void + onShortcuts: () => void +} + +export const ProjectList: Component = (props) => { + const vscode = useVSCode() + const dialog = useDialog() + const select = (target: Record) => + vscode.postMessage({ type: "agentManager.activateSelection", target } as never) + const search = createMemo(() => { + const items: SidebarSearchItem[] = [] + for (const project of props.projects) { + const state = props.states[project.id] + if (!state) continue + const local = props.sessions[project.id]?.filter((session) => session.worktreeId === null) ?? [] + items.push({ + key: `${project.id}:local`, + projectId: project.id, + kind: "local", + group: "contexts", + title: `${project.label} · ${props.t("agentManager.local")}`, + meta: props.local[project.id]?.branch ? [props.local[project.id]!.branch] : [], + search: [project.label, props.t("agentManager.local"), props.local[project.id]?.branch] + .filter(Boolean) + .join(" "), + updatedAt: local.reduce((latest, session) => (session.updatedAt > latest ? session.updatedAt : latest), ""), + state: "idle", + visible: project.expanded, + count: local.length, + }) + for (const worktree of state.worktrees) { + const sessions = props.sessions[project.id]?.filter((session) => session.worktreeId === worktree.id) ?? [] + items.push({ + key: `${project.id}:worktree:${worktree.id}`, + projectId: project.id, + kind: "worktree", + group: "contexts", + title: worktree.label || worktree.branch, + meta: [project.label, worktree.branch], + search: [project.label, worktree.label, worktree.branch, worktree.id].filter(Boolean).join(" "), + updatedAt: worktree.createdAt, + state: "idle", + visible: project.expanded, + worktreeId: worktree.id, + count: sessions.length, + }) + } + for (const session of props.sessions[project.id] ?? []) { + items.push({ + key: `${project.id}:session:${session.id}`, + projectId: project.id, + kind: "session", + group: "sessions", + title: session.title || props.t("agentManager.session.untitled"), + meta: [project.label], + search: [project.label, session.title, session.id].filter(Boolean).join(" "), + updatedAt: session.updatedAt, + state: "idle", + visible: project.expanded, + sessionId: session.id, + location: session.worktreeId ? "worktree" : "local", + worktreeId: session.worktreeId ?? undefined, + }) + } + } + return items + }) + const current = createMemo(() => { + const projectId = props.selectedProject + if (!projectId) return + const worktree = search().find( + (item) => item.projectId === projectId && item.kind === "worktree" && item.worktreeId === props.selection, + ) + if (worktree) return worktree + // On LOCAL, an open session tab is the active item when there is one, so the + // menu highlights the same row the sidebar does. + const session = props.currentSessionID?.() + if (session) { + const match = search().find( + (item) => item.projectId === projectId && item.kind === "session" && item.sessionId === session, + ) + if (match) return match + } + if (props.selection === LOCAL) return search().find((item) => item.key === `${projectId}:local`) + return undefined + }) + const selectSearch = (item: SidebarSearchItem) => { + if (!item.projectId) return + if (item.kind === "local") return select({ projectId: item.projectId, kind: "local" }) + if (item.kind === "worktree") + return select({ projectId: item.projectId, kind: "worktree", worktreeId: item.worktreeId }) + return select({ projectId: item.projectId, kind: "session", sessionId: item.sessionId }) + } + const newWorktree = (projectId: string) => { + const state = props.states[projectId] + dialog.show(() => ( + dialog.close()} + /> + )) + } + const defaultBranch = (projectId: string, selected?: string, detected?: string) => + dialog.show(() => ( + dialog.close()} + /> + )) + return ( + + + + + + + } + onAdd={() => vscode.postMessage({ type: "agentManager.addProject" })} + onSelect={(projectId) => + // Selecting the project itself returns to where the user left off in it; + // the extension resolves its persisted target authoritatively. + vscode.postMessage({ + type: "agentManager.activateSelection", + target: { projectId, kind: "local" }, + restore: true, + }) + } + onRemove={(projectId) => vscode.postMessage({ type: "agentManager.removeProject", projectId })} + onTrust={(projectId) => vscode.postMessage({ type: "agentManager.trustProject", projectId })} + onExpand={(projectId, expanded) => + vscode.postMessage({ type: "agentManager.setProjectExpanded", projectId, expanded }) + } + count={(projectId) => { + const state = props.states[projectId] + return state ? state.worktrees.length + 1 : undefined + }} + body={(project) => ( + select({ projectId, kind: "local" })} + onSelectWorktree={(projectId, worktreeId) => select({ projectId, kind: "worktree", worktreeId })} + onSelectSession={(projectId, sessionId) => select({ projectId, kind: "session", sessionId })} + onNewWorktree={newWorktree} + onDefaultBranch={defaultBranch} + /> + )} + /> + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx new file mode 100644 index 0000000000..2ddd29cb8a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -0,0 +1,269 @@ +import { For, Show, createMemo, createSignal, onCleanup, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { DragDropProvider, DragDropSensors } from "@thisbeyond/solid-dnd" +import type { + AgentManagerStateMessage, + AgentProjectSnapshot, + LocalGitStats, + PRStatus, + ProjectSessionInfo, + WorktreeGitStats, +} from "../src/types/messages" +import type { LanguageContextValue } from "../src/context/language" +import { useVSCode } from "../src/context/vscode" +import { formatRelativeDate } from "../src/utils/date" +import SectionHeader from "./SectionHeader" +import { WorktreeItem } from "./WorktreeItem" +import { UnassignedSessionsSection } from "./UnassignedSessionsSection" +import { ProjectActions } from "./ProjectActions" + +interface Props { + project: AgentProjectSnapshot + state?: AgentManagerStateMessage + busy?: (id: string) => boolean + stats?: Record + local?: LocalGitStats + prs?: Record + sessions?: ProjectSessionInfo[] + selectedProject?: string + selection?: string + bindings: Record + t: LanguageContextValue["t"] + onSelectLocal: (projectId: string) => void + onSelectWorktree: (projectId: string, worktreeId: string) => void + onSelectSession: (projectId: string, sessionId: string) => void + onNewWorktree: (projectId: string) => void + onDefaultBranch: (projectId: string, selected?: string, detected?: string) => void +} + +/** Permanent real sidebar body for one expanded project. */ +export const ProjectSidebarBody: Component = (props) => { + const vscode = useVSCode() + const [pending, setPending] = createSignal() + const [renaming, setRenaming] = createSignal() + const [name, setName] = createSignal("") + let pendingTimer: ReturnType | undefined + onCleanup(() => clearTimeout(pendingTimer)) + /** Arm on the first click, execute on the second, matching the legacy sidebar. */ + const confirmDelete = (worktreeId: string) => { + if (pending() === worktreeId) { + clearTimeout(pendingTimer) + setPending(undefined) + post({ type: "agentManager.deleteWorktree", worktreeId }) + return + } + clearTimeout(pendingTimer) + setPending(worktreeId) + pendingTimer = setTimeout(() => setPending(undefined), 2500) + } + const state = () => props.state + const sessions = (worktreeId: string | null) => + (props.sessions ?? []).filter((item) => item.worktreeId === worktreeId) + const active = () => props.selectedProject === props.project.id + const runs = createMemo(() => Object.fromEntries((state()?.runStatuses ?? []).map((run) => [run.worktreeId, run]))) + const sections = () => state()?.sections ?? [] + const localSessions = () => sessions(null) + const ungrouped = () => state()?.worktrees.filter((wt) => !wt.sectionId) ?? [] + const members = (sectionId: string) => state()?.worktrees.filter((wt) => wt.sectionId === sectionId) ?? [] + const post = (message: Record) => + vscode.postMessage({ ...message, projectId: props.project.id } as never) + + // Escape unmounts the focused rename input, which fires a synchronous blur + // that would re-commit the cancelled value; this flag swallows that blur. + let cancelled = false + const commitRename = (worktreeId: string) => { + if (cancelled) { + cancelled = false + return + } + const label = name().trim() + setRenaming(undefined) + if (label) post({ type: "agentManager.renameWorktree", worktreeId, label }) + } + const cancelRename = () => { + cancelled = true + setRenaming(undefined) + } + + const renderWorktree = (worktree: NonNullable["worktrees"][number]) => ( + + post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId }) + } + onMoveToNewSection={() => + post({ + type: "agentManager.createSection", + name: props.t("agentManager.worktree.newSection"), + worktreeIds: [worktree.id], + }) + } + onClick={() => { + if (pending() === worktree.id) return confirmDelete(worktree.id) + props.onSelectWorktree(props.project.id, worktree.id) + }} + onDelete={(event) => { + event.stopPropagation() + confirmDelete(worktree.id) + }} + onStartRename={(value) => { + setName(value) + setRenaming(worktree.id) + }} + onRenameInput={setName} + onCommitRename={() => commitRename(worktree.id)} + onCancelRename={cancelRename} + onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })} + onCopyPath={() => navigator.clipboard.writeText(worktree.path)} + onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })} + onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })} + /> + ) + + return ( +
+ + +
+ } + > + + +
+
+ + post({ type: "agentManager.createWorktree" })} + onNew={() => props.onNewWorktree(props.project.id)} + onSection={() => + post({ + type: "agentManager.createSection", + name: props.t("agentManager.section.defaultName"), + }) + } + onSetup={() => post({ type: "agentManager.configureSetupScript" })} + onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)} + /> +
+
+ {/* + SectionHeader registers a drop target via solid-dnd, which throws + without a DragDropProvider ancestor and kills the whole render. + Multi-project has no drag-and-drop yet, so this provider is a + no-op context until DnD lands here. + */} + {}} onDragEnd={() => {}}> + + + {(section, index) => ( + post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })} + onRename={(value: string) => + post({ type: "agentManager.renameSection", sectionId: section.id, name: value }) + } + onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })} + onSetColor={(color: string | null) => + post({ type: "agentManager.setSectionColor", sectionId: section.id, color }) + } + isFirst={index() === 0} + isLast={index() === sections().length - 1} + onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })} + onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })} + > + +
+ {renderWorktree} +
+
+
+ )} +
+ {renderWorktree} +
+
+
+ + props.sessions !== undefined} + collapsed={() => state()!.sessionsCollapsed === true} + active={() => undefined} + onToggle={() => post({ type: "agentManager.setSessionsCollapsed", collapsed: !state()!.sessionsCollapsed })} + onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)} + onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })} + onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })} + sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`} + /> +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx new file mode 100644 index 0000000000..c8b39b253d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx @@ -0,0 +1,102 @@ +/** @jsxImportSource solid-js */ + +import { For, Show, untrack, type Accessor, type Component, type JSX } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import type { LanguageContextValue } from "../src/context/language" +import type { AgentProjectSnapshot } from "../src/types/messages" + +interface ProjectsSectionProps { + projects: AgentProjectSnapshot[] + t: LanguageContextValue["t"] + onAdd: () => void + onSelect: (id: string) => void + onRemove: (id: string) => void + onTrust: (id: string) => void + onExpand: (id: string, expanded: boolean) => void + count: (id: string) => number | undefined + tools?: JSX.Element + body: (project: AgentProjectSnapshot) => JSX.Element +} + +const ProjectBodySlot: Component<{ + project: Accessor + body: (project: AgentProjectSnapshot) => JSX.Element +}> = (props) => untrack(() => props.body(props.project())) + +/** + * Stable project accordion. Every expanded project renders the same real body; + * active state only controls detail-pane emphasis. + */ +export const ProjectsSection: Component = (props) => ( +
+
+ +
+ {props.tools} + +
+
+
+ project.id)}> + {(id) => { + const project = () => props.projects.find((item) => item.id === id)! + return ( +
+
+ + + props.onRemove(project().id)} + /> + + +
+ + + +
+ ) + }} +
+
+
+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx new file mode 100644 index 0000000000..a48c5c643c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -0,0 +1,462 @@ +import { For, Show, createMemo, createSignal, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + createSortable, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import type { + LocalGitStats, + ManagedSessionState, + PRStatus, + RunStatus, + SectionState, + SessionInfo, + WorktreeGitStats, + WorktreeState, +} from "../src/types/messages" +import type { LanguageContextValue } from "../src/context/language" +import type { SidebarSearchItem } from "./sidebar-search" +import { LOCAL, adjacentHint } from "./navigate" +import { applyTabOrder, reorderTabs } from "./tab-order" +import { isGroupEnd, isGroupStart, isGrouped, type TopLevelItem } from "./section-helpers" +import { sectionAwareDetector } from "./section-dnd" +import { ConstrainDragXAxis } from "./constrain-drag-x" +import { useVSCode } from "../src/context/vscode" +import SectionHeader from "./SectionHeader" +import { WorktreeItem } from "./WorktreeItem" +import { WorktreeSectionActions } from "./WorktreeSectionActions" +import { UnassignedSessionsSection } from "./UnassignedSessionsSection" +import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" + +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) + +/** Everything the legacy single-project sidebar body reads from the app. */ +export interface SidebarBodyProps { + t: LanguageContextValue["t"] + selection: () => string | null + currentSessionID: () => string | undefined + selectLocal: () => void + selectWorktree: (id: string) => void + isLocalBusy: () => boolean + repoBranch: () => string | undefined + localStats: () => LocalGitStats | undefined + sessionsCollapsed: () => boolean + toggleSessions: () => void + search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined } + bindings: () => Record + defaultBranch: () => string + isGitRepo: () => boolean + loaded: () => boolean + worktreesLoaded: () => boolean + sessionsLoaded: () => boolean + onSearchRef: (ref: SidebarSearchMenuRef | undefined) => void + onSearchSelect: (item: SidebarSearchItem) => void + onCreateWorktree: () => void + onNewWorktree: () => void + onNewSection: () => void + onShortcuts: () => void + onSetup: () => void + onBranch: () => void + sections: () => SectionState[] + sortedWorktrees: () => WorktreeState[] + worktrees: () => WorktreeState[] + ungrouped: () => WorktreeState[] + topLevelItems: () => TopLevelItem[] + worktreesInSection: (id: string) => WorktreeState[] + sidebarOrder: () => { id: string }[] + sidebarWorktreeOrder: () => string[] + setSidebarWorktreeOrder: (fn: (prev: string[]) => string[]) => void + draggingWorktree: () => string | undefined + setDraggingWorktree: (id: string | undefined) => void + moveToSection: (ids: string[], sectionId: string | null) => void + moveSection: (id: string, dir: -1 | 1) => void + renamingSection: () => string | null + setRenamingSection: (id: string | null) => void + managedSessions: () => ManagedSessionState[] + worktreeLabel: (wt: WorktreeState) => string + worktreeSubtitle: (wt: WorktreeState) => string | undefined + pendingDelete: () => string | null + busy: (id: string) => boolean + isAgentBusy: (id: string) => boolean + isStaleWorktree: (id: string) => boolean + shortcutMap: () => Map + worktreeStats: () => Record + prStatuses: () => Record + runStatuses: () => Record + confirmDeleteWorktree: (id: string) => void + handleDeleteWorktree: (id: string, e: MouseEvent) => void + confirmRemoveStaleWorktree: (id: string) => void + unassignedSessions: () => SessionInfo[] + selectUnassigned: (id: string) => void + promoteSession: (id: string) => void + openUnassigned: (id: string) => void + track: (event: string, source: string, action: () => void) => () => void +} + +/** Legacy single-project sidebar body: local repo, worktrees, unassigned sessions. */ +export const SidebarBody: Component = (props) => { + const vscode = useVSCode() + + return ( + <> + {/* Local repo item */} + + + {/* WORKTREES section */} +
+
+ + props.onSearchRef(value)} + onSelect={props.onSearchSelect} + onCreate={props.onCreateWorktree} + onNew={props.onNewWorktree} + onSection={props.onNewSection} + onShortcuts={props.onShortcuts} + onSetup={props.onSetup} + onBranch={props.onBranch} + /> +
+
+ +
+
+
+
+
+ } + > + +
+ + {props.t("agentManager.notGitRepo")} +
+
+ + {(() => { + const [renamingWt, setRenamingWt] = createSignal(null) + const [renameValue, setRenameValue] = createSignal("") + + const startRename = (wtId: string, current: string) => { + setRenamingWt(wtId) + setRenameValue(current) + } + + let cancelled = false + + const commitRename = (wtId: string) => { + if (cancelled) { + cancelled = false + return + } + const value = renameValue().trim() + setRenamingWt(null) + if (!value) return + vscode.postMessage({ type: "agentManager.renameWorktree", worktreeId: wtId, label: value }) + } + + const cancelRename = () => { + cancelled = true + setRenamingWt(null) + } + + const hasSections = createMemo(() => props.sections().length > 0) + const wtIds = createMemo(() => props.sortedWorktrees().map((wt) => wt.id)) + const secIds = createMemo(() => new Set(props.sections().map((s) => s.id))) + const home = () => new Map(props.sortedWorktrees().map((w) => [w.id, w.sectionId] as const)) + const sectionAware = sectionAwareDetector(secIds, home) + + const onWtDragStart = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id === "string") props.setDraggingWorktree(id) + document.body.classList.add("am-wt-dragging-active") + } + const onWtDragOver = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + if (secIds().has(to)) return + props.setSidebarWorktreeOrder((prev) => { + const cur = applyTabOrder( + props.sortedWorktrees().map((w) => ({ id: w.id })), + prev, + ).map((item: { id: string }) => item.id) + return reorderTabs(cur, from, to) ?? prev + }) + } + const onWtDragEnd = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + props.setDraggingWorktree(undefined) + document.body.classList.remove("am-wt-dragging-active") + if (typeof from === "string" && typeof to === "string" && secIds().has(to)) { + props.moveToSection([from], to) + return + } + vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: props.sidebarWorktreeOrder() }) + } + + return ( + + + + + {(() => { + const renderWt = (wt: WorktreeState, idx: () => number, list?: WorktreeState[]) => { + const wtSessions = createMemo(() => + props.managedSessions().filter((ms) => ms.worktreeId === wt.id), + ) + const navHint = () => + adjacentHint( + wt.id, + props.selection() ?? props.currentSessionID() ?? "", + props.sidebarOrder().map((f) => f.id), + props.bindings().previousSession ?? "", + props.bindings().nextSession ?? "", + ) + const groupSize = () => + !wt.groupId ? 0 : props.sortedWorktrees().filter((w) => w.groupId === wt.groupId).length + const sortable = createSortable(wt.id) + void sortable + return ( +
+ + vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }), + )} + sections={props.sections()} + currentSectionId={wt.sectionId} + onMoveToSection={(secId) => props.moveToSection([wt.id], secId)} + onMoveToNewSection={props.track("new_section", "worktree_menu", () => + props.onNewSection(), + )} + onClick={() => { + if (props.pendingDelete() === wt.id) { + props.confirmDeleteWorktree(wt.id) + return + } + props.selectWorktree(wt.id) + }} + onDelete={(e) => props.handleDeleteWorktree(wt.id, e)} + onStartRename={(current) => startRename(wt.id, current)} + onRenameInput={(v) => setRenameValue(v)} + onCommitRename={() => commitRename(wt.id)} + onCancelRename={cancelRename} + onRemoveStale={() => props.confirmRemoveStaleWorktree(wt.id)} + onCopyPath={() => navigator.clipboard.writeText(wt.path)} + onOpen={props.track("open_worktree_window", "worktree_menu", () => + vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }), + )} + /> +
+ ) + } + if (hasSections()) { + const post = vscode.postMessage.bind(vscode) + return ( + + {(item, idx) => { + if (item.kind === "section") { + const sec = item.section + const members = createMemo(() => props.worktreesInSection(sec.id)) + return ( + props.setRenamingSection(null)} + onToggle={() => + post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id }) + } + onRename={(name) => + post({ type: "agentManager.renameSection", sectionId: sec.id, name }) + } + onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })} + onSetColor={(color) => + post({ type: "agentManager.setSectionColor", sectionId: sec.id, color }) + } + isFirst={idx() === 0} + isLast={idx() === props.topLevelItems().length - 1} + onMoveUp={() => props.moveSection(sec.id, -1)} + onMoveDown={() => props.moveSection(sec.id, 1)} + > + +
+ {(wt, wtIdx) => renderWt(wt, wtIdx, members())} +
+
+
+ ) + } + const ug = props.ungrouped() + const wtIdx = () => ug.indexOf(item.wt) + return renderWt(item.wt, wtIdx, ug) + }} +
+ ) + } + return {(wt, idx) => renderWt(wt, idx)} + })()} +
+ + {(() => { + const wt = props.sortedWorktrees().find((w) => w.id === props.draggingWorktree()) + if (!wt) return null + return ( +
+ + {props.worktreeLabel(wt)} +
+ ) + })()} +
+
+ ) + })()} + + + +
+ +
+
+ + (props.selection() === null ? props.currentSessionID() : undefined)} + onToggle={props.toggleSessions} + onSelect={props.selectUnassigned} + onPromote={props.promoteSession} + onOpen={props.openUnassigned} + /> + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx new file mode 100644 index 0000000000..fc1a36b462 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -0,0 +1,279 @@ +import { For, Show, type Component, type JSX } from "solid-js" +import { Button } from "@kilocode/kilo-ui/button" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Icon } from "@kilocode/kilo-ui/icon" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import type { LocalGitStats, RunStatus, WorktreeGitStats } from "../src/types/messages" +import type { LanguageContextValue } from "../src/context/language" +import { LOCAL } from "./navigate" +import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" +import type { tracker } from "./telemetry" +import { SidebarToggleButton } from "./SidebarToggleButton" +import type { DiffScope } from "./diff-scope-state" +import { TerminalDestinationButton } from "./terminal/TerminalDestinationButton" +import type { TerminalDestination } from "../src/types/messages/agent-manager" + +/** Everything the tab bar reads from the app. */ +export interface TabBarProps { + t: LanguageContextValue["t"] + bindings: () => Record + selection: () => string | null + empty: () => boolean + collapsed: boolean + onToggleSidebar: () => void + scroll: { setRef: (el: HTMLDivElement) => void; showLeft: () => boolean; showRight: () => boolean } + ids: () => string[] + renderTab: (id: string) => JSX.Element + newTab: () => JSX.Element + onDragStart: (event: DragEvent) => void + onDragEnd: (event: DragEvent) => void + onDragOver: (event: DragEvent) => void + onRelease: () => void + overlay: () => { title?: string } | undefined + localStats: () => LocalGitStats | undefined + worktreeStats: () => Record + applyState: () => { status: string } | undefined + /** Active diff scope; applying to local is only possible from the branch scope. */ + reviewScope: () => DiffScope + onOpen: () => void + onApply: () => void + runStatuses: () => Record + runConfigured: () => boolean + onRun: (id: string) => void + onConfigureRun: () => void + diffOpen: () => boolean + reviewActive: () => boolean + onToggleDiff: () => void + onToggleReview: () => void + terminalDestination: () => TerminalDestination + terminalDestinationActive: () => boolean + terminalKeybind: () => string + onTerminalDestinationOpen: () => void + onTerminalDestinationChoose: (destination: TerminalDestination) => void + track: ReturnType["click"] +} + +/** Tab bar with sortable session/terminal/review tabs and the run/diff/apply actions. */ +export const TabBar: Component = (props) => ( + +
+ +
+
+ } + > + + + +
+
+ +
+
+
+
+
+ + {(id) => props.renderTab(id)} + +
+
+
+
+ +
+
+ {props.newTab()} +
+ +
+ {(() => { + const sel = () => props.selection() + const isWorktree = () => typeof sel() === "string" && sel() !== LOCAL + const stats = () => { + if (sel() === LOCAL) return props.localStats() + return typeof sel() === "string" ? props.worktreeStats()[sel() as string] : undefined + } + const hasChanges = () => { + const s = stats() + return s && (s.files > 0 || s.additions > 0 || s.deletions > 0) + } + const applyBusy = () => { + const state = props.applyState() + if (!state) return false + return state.status === "checking" || state.status === "applying" + } + return ( + <> + + <> + + + + + + + + + + {(() => { + const rid = () => (sel() === LOCAL ? LOCAL : (sel() as string)) + const rs = () => props.runStatuses()[rid()] + const active = () => rs()?.state === "running" || rs()?.state === "stopping" + const configured = props.runConfigured + const title = () => (configured() ? (active() ? "Stop" : "Run") : "Configure run script") + return ( + + + + + + ) => ( + + )} + /> + + + + + {props.t("agentManager.run.configure")} + + + + + + ) + })()} + + + + + + ) + })()} + + + + + + {/* 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. */} + +
+
+ + + {(tab) => ( +
+ {tab().title || props.t("agentManager.session.untitled")} +
+ )} +
+
+ +
+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx index 606d579eb2..966fee8234 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx @@ -16,6 +16,7 @@ interface Props { onSelect: (id: string) => void onPromote: (id: string) => void onOpen: (id: string) => void + sidebarId?: (id: string) => string } export const UnassignedSessionsSection: Component = (props) => { @@ -61,7 +62,7 @@ export const UnassignedSessionsSection: Component = (props) => {
) @@ -126,7 +130,8 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS * terminal stays mounted, visibility is toggled via `opacity` / * `pointer-events` / `inert` only. The layer is scoped to * `contextKey` — side terminals from other contexts stay composed in - * the background and never refit. + * the background and never refit — and within a context only the + * active strip tab's terminal is shown. */ export function renderSideTerminalLayer(props: { state: TerminalStateControls @@ -137,7 +142,10 @@ export function renderSideTerminalLayer(props: {
{(term) => { - const active = () => props.visible() && term.contextKey === props.contextKey() + const active = () => + props.visible() && + term.contextKey === props.contextKey() && + props.state.sideActiveFor(term.contextKey) === term.id return (
props.state.scriptStatus(term.id)} onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)} + onTitleChange={(title) => props.state.setTitle(term.id, title)} />
) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts index 3c7fe39145..907625dd16 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -7,23 +7,70 @@ * 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. + * + * ## Destination state ownership + * + * The VS Code setting is application-scoped, so one value is shared by + * every window and echoed back via `terminal.destinationChanged` + * whenever ANY window rewrites it. Two windows can therefore fight: + * picking "VS Code terminal" in worktree window B would silently flip + * the routing of the panel in window A. To keep each panel consistent, + * an explicit dropdown pick is stored per panel (webview state) and + * wins over remote echoes; the setting only drives panels that never + * picked a destination themselves (it stays the default for new ones). */ import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import type { TerminalDestination } from "../../src/types/messages/agent-manager" +import { LOCAL } from "../navigate" + +/** Read the panel-local destination choice from raw webview state. */ +export function readSavedDestination(state: Record | undefined): TerminalDestination | undefined { + const value = state?.terminalDestination + return value === "agentManager" || value === "vscode" ? value : undefined +} + +export type VscodeTerminalRequest = + | { type: "agentManager.showTerminal"; sessionId: string } + | { type: "agentManager.showWorktreeTerminal"; worktreeId: string } + | { type: "agentManager.showLocalTerminal" } + +/** Carry the panel-local dropdown choice with every Run click. */ +export function resolveRunScriptRequest(worktreeId: string, destination: TerminalDestination) { + return { type: "agentManager.runScript" as const, worktreeId, destination } +} + +/** + * Pick the message the terminal button / Focus Terminal shortcut sends + * when the destination is the VS Code integrated terminal. The fallback + * chain exists so the shortcut never dead-ends: activating a terminal + * tab clears the current session, and a worktree may have no sessions + * at all. Extracted from AgentManagerApp.tsx (max-lines cap). + */ +export function resolveVscodeTerminalRequest( + selection: string | null, + currentSessionID: string | undefined, + sessionForWorktree: (worktreeId: string) => string | undefined, +): VscodeTerminalRequest { + const id = currentSessionID ?? (selection && selection !== LOCAL ? sessionForWorktree(selection) : undefined) + if (id) return { type: "agentManager.showTerminal", sessionId: id } + if (selection && selection !== LOCAL) return { type: "agentManager.showWorktreeTerminal", worktreeId: selection } + return { type: "agentManager.showLocalTerminal" } +} interface Handlers { requestSide(): void - closeSide(): boolean + ensureSide(): void + closeSide(terminalId: string): boolean } export interface SideTerminalDeps { handlers: Handlers /** True while the right-side inspector shows the terminal. */ visible: Accessor - /** True while the side terminal itself holds DOM focus. */ - focused: Accessor + /** Id of the side terminal holding DOM focus, if any. */ + focusedId: Accessor /** Leave terminal mode; the terminal stays alive in the background. */ hide: () => void /** Move focus back to the chat composer. */ @@ -32,10 +79,20 @@ export interface SideTerminalDeps { track: (button: string, surface: string, properties: Record) => void /** Open or focus the VS Code integrated terminal for the active context. */ openVscode: () => void + /** Panel-local choice restored from webview state, if the user ever + * picked one in this panel. */ + saved: TerminalDestination | undefined + /** Persist the panel-local choice so it survives webview reloads. */ + save: (destination: TerminalDestination) => void + /** Platform override for tests; the workbench keybinding is Cmd on + * macOS and Ctrl elsewhere, and the local fallback must match it. */ + mac?: boolean } export function createSideTerminal(deps: SideTerminalDeps) { - const [destination, setDestination] = createSignal("vscode") + const [local, setLocal] = createSignal(deps.saved) + const [destination, setDestination] = createSignal(deps.saved ?? "vscode") + if (deps.saved) deps.postMessage({ type: "agentManager.terminal.destinationSelected", destination: deps.saved }) /** * Hiding while the terminal holds focus would strand the cursor on @@ -49,7 +106,7 @@ export function createSideTerminal(deps: SideTerminalDeps) { const toggle = () => { if (deps.visible()) { - const was = deps.focused() + const was = deps.focusedId() !== undefined deps.hide() handoff(was) return @@ -57,12 +114,22 @@ export function createSideTerminal(deps: SideTerminalDeps) { deps.handlers.requestSide() } - /** Kill the current context's side terminal (or cancel its in-flight - * create) and hide the panel. */ + /** Keep an open terminal panel useful when its worktree context changes. */ + const syncContext = (key: string, previous: string | undefined) => { + if (key === previous || !deps.visible()) return + queueMicrotask(() => { + if (deps.visible()) deps.handlers.ensureSide() + }) + } + + /** 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. */ const close = (): boolean => { - const was = deps.focused() - const done = deps.handlers.closeSide() - if (done) handoff(was) + const id = deps.focusedId() + if (!id) return false + const done = deps.handlers.closeSide(id) + if (done) handoff(true) return done } @@ -78,17 +145,72 @@ export function createSideTerminal(deps: SideTerminalDeps) { } /** - * Dropdown pick. Applied locally right away so the button reacts - * without a round trip, then persisted as a VS Code setting; the - * extension echoes it back via `terminal.destinationChanged`. - * The key is relative to the `kilo-code.new` section, matching every - * other `updateSetting` sender. + * Dropdown pick. The choice is panel-local and sticky: it is kept in + * webview state and beats later `terminal.destinationChanged` echoes + * caused by other windows rewriting the shared application-scoped + * setting. The provider still persists it as the default for new panels, + * while setup uses this panel-local value immediately. */ const choose = (target: TerminalDestination) => { deps.track("terminal_destination", "tab_toolbar", { destination: target }) + setLocal(target) setDestination(target) - deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target }) + deps.save(target) + deps.postMessage({ type: "agentManager.terminal.destinationSelected", destination: target }) } - return { destination, setDestination, toggle, close, openPreferred, choose } + /** + * Apply a remote default (initial `agentManager.state` payload or a + * live `terminal.destinationChanged` echo). Ignored once the user + * picked a destination in this panel — their choice wins over every + * echo, including ones triggered by this panel's own `choose` write. + */ + const syncDefault = (target: TerminalDestination) => { + if (local()) return + setDestination(target) + } + + /** + * Cmd/Ctrl+/ pressed while the webview holds DOM focus. VS Code normally + * forwards the keybinding to the workbench too, and the extension echoes + * it back as a showTerminal action message; `echo()` consumes one pending + * echo per press so one keypress never toggles twice while an unrelated + * invocation (command palette, terminal-focused press) still runs. + * Handling the key locally keeps the shortcut working when the + * forwarding path drops it (e.g. the chat prompt input is focused). + * The modifier matches the declared keybinding: Cmd on macOS, Ctrl + * elsewhere — accepting both would hijack the other platform's combo + * (and any user keybinding on it) without a matching workbench binding. + */ + const mac = deps.mac ?? (typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)) + let pending = 0 + let lastPress = 0 + const ECHO_MS = 500 + + const press = (e: KeyboardEvent): boolean => { + const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey + if (e.key !== "/" || e.shiftKey || e.altKey || !mod) return false + // An echo either arrives promptly or never; drop the backlog of a + // dropped echo so it cannot swallow a later unrelated invocation. + if (Date.now() - lastPress > ECHO_MS) pending = 0 + pending++ + lastPress = Date.now() + openPreferred("keyboard_shortcut") + return true + } + + /** Consumes one pending extension echo of a local `press`. The timeout is + * only a safety valve for an echo that never arrives; an echo that + * outlasts it is indistinguishable from a real invocation and must run. */ + const echo = () => { + if (pending === 0) return false + if (Date.now() - lastPress > ECHO_MS) { + pending = 0 + return false + } + pending-- + return true + } + + return { destination, syncDefault, syncContext, toggle, close, openPreferred, choose, press, echo } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index 3e9fba7af1..f5b4d4003d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -15,15 +15,24 @@ import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import { LOCAL } from "../navigate" -import type { ExtensionMessage } from "../../src/types/messages/extension-messages" +import type { + ExtensionMessage, + ScriptTerminalKind, + ScriptTerminalView, +} from "../../src/types/messages/extension-messages" import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager" export type { TerminalFont } /** Prefix used for terminal tab IDs in the webview (mirrors terminal-manager.ts). */ export const TERMINAL_PREFIX = "terminal:" +export const SCRIPT_TERMINAL_PREFIX = "script:" -export const isTerminalTabId = (id: string): boolean => id.startsWith(TERMINAL_PREFIX) +export const isTerminalTabId = (id: string): boolean => + id.startsWith(TERMINAL_PREFIX) || id.startsWith(SCRIPT_TERMINAL_PREFIX) + +/** Status is separate from mounted xterm records so snapshot updates never remount them. */ +export type ScriptTerminalStatus = Pick /** One row in `terminalsByContext`. `wsUrl` is short-lived and never persisted. */ export interface TerminalTabState { @@ -32,6 +41,8 @@ export interface TerminalTabState { wsUrl: string font: TerminalFont placement: TerminalPlacement + /** Provider-owned script terminal, never created through the webview create flow. */ + kind?: ScriptTerminalKind } /** Terminal row enriched with the sidebar context it belongs to. Used by @@ -50,11 +61,9 @@ export interface TerminalFocusRequest { } /** A create request for a side terminal that has not been answered yet. - * `cancelled` is set when the user closes the panel while the PTY is - * still starting; the late `created` answer is then closed again. */ + * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string - cancelled: boolean } export interface TerminalStateControls { @@ -65,6 +74,12 @@ export interface TerminalStateControls { remove(terminalId: string): TerminalTabStateWithContext | undefined /** Resolve the context key a terminal lives in, if any. */ contextFor(terminalId: string): string | undefined + /** Whether a terminal belongs to a provider-owned script (Run/Setup). */ + isScript(terminalId: string): boolean + /** Reactive script state, kept apart from stable xterm terminal records. */ + scriptStatus(terminalId: string): ScriptTerminalStatus | undefined + /** Reconcile a complete provider-owned script terminal snapshot. Returns newly hydrated records. */ + syncScripts(views: ScriptTerminalView[]): TerminalTabStateWithContext[] /** All tab terminals for the given sidebar selection. */ forSelection(selection: string | null): TerminalTabStateWithContext[] /** Map of { id -> tab state } for O(1) lookup. */ @@ -75,10 +90,14 @@ export interface TerminalStateControls { all: Accessor /** Every side terminal across every context (for the side-panel layer). */ sides: Accessor - /** The side terminal of the current context, if any. */ - side: Accessor - /** The side terminal of an arbitrary context, if any. */ - sideForContext(contextKey: string): TerminalTabStateWithContext | undefined + /** Every side terminal of the given context, in creation order. */ + sidesForContext(contextKey: string): TerminalTabStateWithContext[] + /** Id of the active side terminal for a context. */ + sideActiveFor(contextKey: string): string | undefined + /** Mark a side terminal as the visible one for its context. */ + setSideActive(contextKey: string, terminalId: string): void + /** Id of the side terminal holding DOM focus in the current context, if any. */ + sideFocusedId(): string | undefined /** Context key for the current sidebar selection, or `undefined` when nothing is selected. */ currentKey: Accessor /** Context key for the side panel: like `currentKey` but unassigned @@ -96,6 +115,13 @@ export interface TerminalStateControls { requestFocus(id: string): void /** True when the given remembered tab id points to a live terminal for the given selection. */ hasRemembered(selection: string | null, remembered: string | undefined): boolean + /** Live display title for a terminal: the OSC-provided title when the + * shell/program set one, otherwise the create-time title. */ + title(terminalId: string): string | undefined + /** Record an OSC title change for a terminal. Kept outside the + * terminal records so `` reference stability (and therefore the + * mounted xterm instances) is preserved. */ + setTitle(terminalId: string, title: string): void /** * Persist a new order for a context's terminals (webview-memory only — * terminals are ephemeral and never round-trip through the extension @@ -109,12 +135,16 @@ export interface TerminalStateControls { * was applied, false otherwise so the caller can fall through. */ reorderDrag(from: string, to: string): boolean - /** Request id of the in-flight side-terminal create for a context. */ - pendingSide(contextKey: string): string | undefined + /** + * Apply a drag-over reorder within a context's side terminals (the + * side-panel strip). Returns true when both ends are side terminals + * of that context. + */ + reorderSideDrag(contextKey: string, from: string, to: string): boolean + /** Request ids of the in-flight side-terminal creates for a context. */ + pendingSide(contextKey: string): boolean /** Mark a side-terminal create as in flight for a context. */ beginSide(contextKey: string, createId: string): void - /** Cancel the in-flight create; returns true when one was pending. */ - cancelSide(contextKey: string): boolean /** Settle a create request; returns it so the caller can validate. */ completeSide(createId: string): SideRequest | undefined } @@ -149,10 +179,18 @@ export function createTerminalState(selection: Accessor): Termina const [activeId, setActiveId] = createSignal() const [focusedId, setFocusedId] = createSignal() const [focusRequest, setFocusRequest] = createSignal() + // OSC-provided titles, keyed by terminal id. Separate from the terminal + // records on purpose: replacing a record would remount its xterm via + // reference inequality (see the module comment above). + const [titles, setTitles] = createSignal>({}) + const [scripts, setScripts] = createSignal>({}) + // Active side terminal per context. + const [actives, setActives] = createSignal>({}) let focusSerial = 0 // In-flight side-terminal creates, keyed both ways: per context (what - // the panel shows) and per request id (what the answer carries). - const [pending, setPending] = createSignal>({}) + // the panel shows) and per request id (what the answer carries). A + // context can have several creates in flight at once. + const [pending, setPending] = createSignal>({}) const requests = new Map() const currentKey = (): string | undefined => { @@ -195,8 +233,35 @@ export function createTerminalState(selection: Accessor): Termina return out } - const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side") - const side = () => sideForContext(sideKey()) + const sidesForContext = (key: string) => (terminalsByContext()[key] ?? []).filter((t) => t.placement === "side") + const sideActiveFor = (key: string) => actives()[key] + const sideFocusedId = () => { + const id = focusedId() + if (!id) return undefined + return sidesForContext(sideKey()).some((t) => t.id === id) ? id : undefined + } + + const setSideActive = (key: string, terminalId: string) => { + setActives((prev) => (prev[key] === terminalId ? prev : { ...prev, [key]: terminalId })) + } + + const title = (terminalId: string): string | undefined => { + const key = contextFor(terminalId) + if (!key) return undefined + const term = terminalsByContext()[key]?.find((t) => t.id === terminalId) + if (!term) return undefined + // Script terminals always retain their semantic title, even when their + // command emits OSC title sequences. + if (term.kind) return term.title + return titles()[terminalId] ?? term.title + } + + const setTitle = (terminalId: string, next: string) => { + if (isScript(terminalId)) return + const trimmed = next.trim() + if (!trimmed) return + setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed })) + } const lookup = () => new Map(current().map((t) => [t.id, t])) @@ -207,6 +272,16 @@ export function createTerminalState(selection: Accessor): Termina return undefined } + const isScript = (terminalId: string): boolean => { + const key = contextFor(terminalId) + return terminalsByContext()[key ?? ""]?.some((term) => term.id === terminalId && term.kind !== undefined) ?? false + } + + const scriptStatus = (terminalId: string): ScriptTerminalStatus | undefined => { + if (!isScript(terminalId)) return undefined + return scripts()[terminalId] + } + const forSelection = (sel: string | null): TerminalTabStateWithContext[] => { if (sel === null) return [] const key = sel === LOCAL ? LOCAL : sel @@ -218,9 +293,6 @@ export function createTerminalState(selection: Accessor): Termina setTerminalsByContext((prev) => { const list = prev[key] ?? [] if (list.some((t) => t.id === term.id)) return prev - // One side terminal per context; the message handler dedupes via - // pending requests, this guard covers stale double answers. - if (term.placement === "side" && list.some((t) => t.placement === "side")) return prev const enriched: TerminalTabStateWithContext = { ...term, contextKey: key } return { ...prev, [key]: [...list, enriched] } }) @@ -238,9 +310,128 @@ export function createTerminalState(selection: Accessor): Termina return next }) if (focusedId() === terminalId) setFocusedId(undefined) + // A removed active side terminal hands activation to the last + // remaining one of its context, so the panel never shows a dead slot. + if (removed?.placement === "side" && actives()[key] === terminalId) { + const rest = sidesForContext(key) + setActives((prev) => { + const next = { ...prev } + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + return next + }) + } + if (titles()[terminalId] !== undefined) { + setTitles((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } + if (removed?.kind && scripts()[terminalId] !== undefined) { + setScripts((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } return removed } + const syncScripts = (views: ScriptTerminalView[]): TerminalTabStateWithContext[] => { + const ids = new Set(views.map((view) => view.terminalId)) + const added: TerminalTabStateWithContext[] = [] + const removed: TerminalTabStateWithContext[] = [] + + setTerminalsByContext((prev) => { + let changed = false + const next: Record = {} + for (const [key, list] of Object.entries(prev)) { + const kept = list.filter((term) => { + if (term.kind === undefined || ids.has(term.id)) return true + removed.push(term) + changed = true + return false + }) + if (kept.length > 0) next[key] = kept + } + for (const view of views) { + const target = view.worktreeId ?? LOCAL + const key = view.projectId ? `${view.projectId}:${target}` : target + const list = next[key] ?? [] + if (list.some((term) => term.id === view.terminalId)) continue + const term: TerminalTabStateWithContext = { + id: view.terminalId, + title: view.title, + wsUrl: view.wsUrl, + font: view.font, + placement: "side", + kind: view.kind, + contextKey: key, + } + next[key] = [...list, term] + added.push(term) + changed = true + } + return changed ? next : prev + }) + + const states: Record = {} + for (const view of views) { + const status: ScriptTerminalStatus = { state: view.state, kind: view.kind } + if (view.exitCode !== undefined) status.exitCode = view.exitCode + states[view.terminalId] = status + } + setScripts((prev) => { + const keys = Object.keys(states) + if (keys.length !== Object.keys(prev).length) return states + for (const id of keys) { + const before = prev[id] + const after = states[id] + if (before?.state !== after?.state || before?.exitCode !== after?.exitCode || before?.kind !== after?.kind) + return states + } + return prev + }) + + if (added.length > 0) { + setActives((prev) => { + let changed = false + const next = { ...prev } + for (const term of added) { + if (next[term.contextKey]) continue + next[term.contextKey] = term.id + changed = true + } + return changed ? next : prev + }) + } + + if (removed.length > 0) { + const removedIds = new Set(removed.map((term) => term.id)) + if (focusedId() && removedIds.has(focusedId()!)) setFocusedId(undefined) + if (activeId() && removedIds.has(activeId()!)) setActiveId(undefined) + setTitles((prev) => { + const next = { ...prev } + for (const id of removedIds) delete next[id] + return next + }) + setActives((prev) => { + let changed = false + const next = { ...prev } + for (const key of new Set(removed.map((term) => term.contextKey))) { + if (!prev[key] || !removedIds.has(prev[key]!)) continue + const rest = sidesForContext(key) + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + changed = true + } + return changed ? next : prev + }) + } + return added + } + const requestFocus = (id: string) => { focusSerial++ setFocusRequest({ id, serial: focusSerial }) @@ -299,34 +490,58 @@ export function createTerminalState(selection: Accessor): Termina return true } - const pendingSide = (key: string) => pending()[key] - - const beginSide = (key: string, createId: string) => { - requests.set(createId, { contextKey: key, cancelled: false }) - setPending((prev) => ({ ...prev, [key]: createId })) - } - - const cancelSide = (key: string): boolean => { - const id = pending()[key] - if (!id) return false - const request = requests.get(id) - if (request) request.cancelled = true - setPending((prev) => { - const next = { ...prev } - delete next[key] - return next + /** + * Reorder the side terminals of a context by moving `from` to `to`'s + * position (side-panel strip drag-and-drop). Tab terminals keep their + * leading positions; only the side subset is reshuffled. The order + * lives in the same `terminalsByContext` list, so it survives sidebar + * context switches for the lifetime of the webview. + */ + const reorderSideDrag = (key: string, from: string, to: string): boolean => { + const order = sidesForContext(key).map((t) => t.id) + const fi = order.indexOf(from) + const ti = order.indexOf(to) + if (fi === -1 || ti === -1 || fi === ti) return false + const next = [...order] + next.splice(fi, 1) + next.splice(ti, 0, from) + setTerminalsByContext((prev) => { + const list = prev[key] + if (!list || list.length === 0) return prev + const tabs = list.filter((t) => t.placement === "tab") + const sides = list.filter((t) => t.placement === "side") + const byId = new Map(sides.map((t) => [t.id, t])) + const moved: TerminalTabStateWithContext[] = [] + for (const id of next) { + const t = byId.get(id) + if (t) moved.push(t) + } + // Fresh terminals that appeared mid-drag keep their tail position. + for (const t of sides) if (!moved.includes(t)) moved.push(t) + const ordered = [...tabs, ...moved] + if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev + return { ...prev, [key]: ordered } }) return true } + const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 + + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key }) + setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) + } + const completeSide = (createId: string): SideRequest | undefined => { const request = requests.get(createId) if (!request) return undefined requests.delete(createId) setPending((prev) => { - if (prev[request.contextKey] !== createId) return prev + const list = (prev[request.contextKey] ?? []).filter((id) => id !== createId) + if (list.length === (prev[request.contextKey]?.length ?? 0)) return prev const next = { ...prev } - delete next[request.contextKey] + if (list.length === 0) delete next[request.contextKey] + else next[request.contextKey] = list return next }) return request @@ -336,13 +551,18 @@ export function createTerminalState(selection: Accessor): Termina add, remove, contextFor, + isScript, + scriptStatus, + syncScripts, forSelection, lookup, current, all, sides, - side, - sideForContext, + sidesForContext, + sideActiveFor, + setSideActive, + sideFocusedId, currentKey, sideKey, activeId, @@ -352,11 +572,13 @@ export function createTerminalState(selection: Accessor): Termina focusRequest, requestFocus, hasRemembered, + title, + setTitle, reorder, reorderDrag, + reorderSideDrag, pendingSide, beginSide, - cancelSide, completeSide, } } @@ -376,8 +598,6 @@ export interface TerminalHandlerDeps { onRemove?: () => void /** Reveal the right-side inspector in terminal mode. */ onShowSide: (contextKey: string) => void - /** Leave terminal mode without killing the terminal. */ - onHideSide: () => void /** Resolve the current sidebar selection for the new-terminal helper. */ getSelection: () => string | null /** Sentinel value for the LOCAL sidebar selection. */ @@ -417,33 +637,61 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { }) } - /** - * Reveal the side panel and create-or-focus the context's side - * terminal. Reuses the existing terminal when one is alive, dedupes - * against an in-flight create, and never touches the tab strip or - * the chat session. - */ - const requestSide = () => { + const createSide = () => { const key = deps.state.sideKey() - deps.onShowSide(key) - const existing = deps.state.sideForContext(key) - if (existing) { - deps.state.requestFocus(existing.id) - return - } - if (deps.state.pendingSide(key)) return const id = newId() deps.state.beginSide(key, id) - const sel = deps.getSelection() deps.postMessage({ type: "agentManager.terminal.create", createId: id, placement: "side", - worktreeId: sel === null || sel === deps.LOCAL ? null : sel, + worktreeId: key === deps.LOCAL ? null : key, }) } + /** + * Always create a fresh side terminal for the current context (the + * panel's `+` action and empty state). Multiple creates may be in + * flight at once; each lands as its own tab in the panel strip. + */ + const addSide = () => { + deps.onShowSide(deps.state.sideKey()) + createSide() + } + + /** Ensure the current context has a terminal without changing panel mode. */ + const ensureSide = () => { + const key = deps.state.sideKey() + if (deps.state.sidesForContext(key).length > 0 || deps.state.pendingSide(key)) return + createSide() + } + + /** + * Reveal the side panel and focus the context's active side terminal, + * creating one when the context has none. Never touches the tab strip + * or the chat session. + */ + const requestSide = () => { + const key = deps.state.sideKey() + deps.onShowSide(key) + const existing = deps.state.sidesForContext(key) + if (existing.length > 0) { + const active = deps.state.sideActiveFor(key) ?? existing[existing.length - 1]!.id + deps.state.setSideActive(key, active) + deps.state.requestFocus(active) + return + } + ensureSide() + } + const closeTerminal = (terminalId: string) => { + // Script terminals transition through a provider-owned stopping snapshot. + // Keep their xterm mounted until closure is confirmed by a snapshot or + // terminal.closed message so live output is never discarded early. + if (deps.state.isScript(terminalId)) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) + return + } deps.onRemove?.() const ids = deps.tabIds() const idx = ids.indexOf(terminalId) @@ -477,19 +725,57 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } /** - * Kill the current context's side terminal and hide the panel. With - * a create still in flight, cancels it instead — the late answer is - * closed by the message handler. + * Kill one side terminal. The panel stays open on the remaining + * terminals (or the empty state when this was the last one) — hiding + * is the toggle's job, not the close button's. Active-tab fallback + * is handled by the state layer. */ - const closeSide = () => { - const term = deps.state.side() - deps.onHideSide() - if (!term) return deps.state.cancelSide(deps.state.sideKey()) - deps.state.remove(term.id) - deps.postMessage({ type: "agentManager.terminal.close", terminalId: term.id }) + const closeSide = (terminalId: string): boolean => { + // Validate before mutating: dropping a non-side record here would + // 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 + if (term.kind) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) + return true + } + deps.state.remove(terminalId) + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } + /** + * Kill every side terminal of a context except one, and make the + * survivor the visible tab: the side-strip counterpart of the tab + * bar's "Close Others". + */ + const closeSideOthers = (terminalId: string) => { + const key = deps.state.contextFor(terminalId) + if (!key) return + for (const term of deps.state.sidesForContext(key)) { + if (term.id === terminalId) continue + closeSide(term.id) + } + deps.state.setSideActive(key, terminalId) + deps.state.requestFocus(terminalId) + } + + /** Deliberately stop a running script terminal: kills its process tree. */ + const stopSide = (terminalId: string): boolean => { + const term = deps.state.sides().find((t) => t.id === terminalId) + if (!term?.kind) return false + deps.postMessage({ type: "agentManager.terminal.stop", terminalId }) + return true + } + + /** Make a side terminal the visible one in its panel and focus it. */ + const selectSide = (terminalId: string) => { + const key = deps.state.contextFor(terminalId) + if (!key) return + deps.state.setSideActive(key, terminalId) + deps.state.requestFocus(terminalId) + } + const middleClick = (terminalId: string, e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -504,7 +790,21 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { return true } - return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive } + return { + closeTerminal, + closeSide, + closeSideOthers, + stopSide, + selectSide, + middleClick, + activate, + deactivate, + requestNew, + requestSide, + ensureSide, + addSide, + closeActive, + } } export interface TerminalMessageHandlerDeps { @@ -528,11 +828,14 @@ export interface TerminalMessageHandlerDeps { onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ onSideClosed?: (contextKey: string) => void + /** A newly hydrated running script terminal belongs to the selected context. */ + onScriptRunning?: (contextKey: string, terminalId: string) => void /** The destination setting changed (live settings sync). */ onDestinationChanged?: (destination: TerminalDestination) => void } type CreatedMessage = Extract +type ScriptTerminalsMessage = Extract function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId @@ -545,14 +848,17 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { } if (msg.placement === "side") { // Side terminals are answered to a specific pending request. A - // missing, cancelled, or context-mismatched request means the user - // already moved on — close the PTY again instead of leaking it. + // missing or context-mismatched request means the webview was + // reloaded (or the context is gone) — close the PTY again instead + // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.cancelled || request.contextKey !== contextKey) { + if (!request || request.contextKey !== contextKey) { deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } deps.state.add(msg.worktreeId, term) + // The newest terminal becomes the visible one in its panel. + deps.state.setSideActive(contextKey, msg.terminalId) deps.onSideCreated?.(contextKey, msg.terminalId) return } @@ -563,6 +869,13 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { deps.activate(msg.terminalId) } +function handleScriptTerminals(deps: TerminalMessageHandlerDeps, msg: ScriptTerminalsMessage) { + const added = deps.state.syncScripts(msg.terminals) + for (const term of added) { + if (deps.state.scriptStatus(term.id)?.state === "running") deps.onScriptRunning?.(term.contextKey, term.id) + } +} + /** * Wire handlers for the inbound terminal messages. Returns a dispatcher * that accepts each message type and returns true if it handled the @@ -575,6 +888,10 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { handleCreated(deps, msg) return true } + if (msg.type === "agentManager.scriptTerminals") { + handleScriptTerminals(deps, msg) + return true + } if (msg.type === "agentManager.terminal.closed") { const removed = deps.state.remove(msg.terminalId) if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined) @@ -583,8 +900,6 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { } if (msg.type === "agentManager.terminal.error") { const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined - // Errors for requests the user already cancelled are noise. - if (request?.cancelled) return true if (request) deps.onSideError?.(request.contextKey) deps.showError(msg.message) return true diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts new file mode 100644 index 0000000000..57692d649c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -0,0 +1,137 @@ +/** + * Worktree diff data for Agent Manager. + * + * Owns the per-session diff map, the panel loading flag, and the per-file + * pending set, plus the backend message handlers that fill them and the + * helpers that request individual files. Extracted from `AgentManagerApp.tsx` + * so the app component only routes the diff messages and reads the signals. + */ + +import { createSignal, type Accessor } from "solid-js" +import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" +import { parseDiffId } from "./diff-scope-state" +import type { useVSCode } from "../src/context/vscode" +import type { + AgentManagerWorktreeDiffFileMessage, + AgentManagerWorktreeDiffLoadingMessage, + AgentManagerWorktreeDiffMessage, + AgentManagerWorktreeDiffNoticeMessage, + WorktreeFileDiff, +} from "../src/types/messages" + +/** + * Decompose a composite diff id (`ctx#scope`, or `ctx#session:`) into the + * wire fields the extension expects. Bare ids (no scope separator) parse to + * the default branch scope. + */ +export function wireDiffId(id: string) { + const { ctx, scope, sessionId } = parseDiffId(id) + return { sessionId: ctx, scope, diffSessionId: sessionId } +} + +export function createWorktreeDiffs(vscode: ReturnType) { + const [diffDatas, setDiffDatas] = createSignal>({}) + const [diffLoading, setDiffLoading] = createSignal(false) + const [diffNotices, setDiffNotices] = createSignal>({}) + const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { + setDiffFileLoading((prev) => { + const session = prev[sessionId] ?? {} + if (value) { + if (session[file]) return prev + return { + ...prev, + [sessionId]: { ...session, [file]: true }, + } + } + + if (!session[file]) return prev + const next = { ...session } + delete next[file] + if (Object.keys(next).length === 0) { + const result = { ...prev } + delete result[sessionId] + return result + } + return { + ...prev, + [sessionId]: next, + } + }) + } + + /** Lazily load a single file's full diff for the given composite diff id. */ + const requestDiffFile = (id: string, file: string) => { + if (diffFileLoading()[id]?.[file]) return + setDiffFilePending(id, file, true) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wireDiffId(id) }) + } + + /** Files the backend flagged as stale in a merged update need a fresh fetch. */ + const refreshStaleDiffs = (id: string, files: Set) => { + const loading = diffFileLoading()[id] ?? {} + for (const file of files) { + if (loading[file]) continue + setDiffFilePending(id, file, true) + vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wireDiffId(id) }) + } + } + + /** Files currently being fetched for a session, for per-file spinners. */ + const diffFileLoadingFor = (sessionId: Accessor) => { + const id = sessionId() + if (!id) return new Set() + return new Set(Object.keys(diffFileLoading()[id] ?? {})) + } + + // Backend messages. + + const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + let staleFiles: Set | undefined + setDiffDatas((prev) => { + const existing = prev[ev.sessionId] + const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } + staleFiles = merged.stale + const next = merged.diffs + if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev + return { ...prev, [ev.sessionId]: next } + }) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + } + + const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + if (ev.diff) { + setDiffDatas((prev) => { + const existing = prev[ev.sessionId] ?? [] + const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) + return { ...prev, [ev.sessionId]: next } + }) + setDiffFilePending(ev.sessionId, ev.diff.file, false) + return + } + setDiffFilePending(ev.sessionId, ev.file, false) + } + + const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { + setDiffLoading(ev.loading) + } + + const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => { + setDiffNotices((prev) => ({ ...prev, [ev.sessionId]: ev.notice })) + } + + return { + diffDatas, + diffLoading, + setDiffLoading, + diffNotices, + requestDiffFile, + refreshStaleDiffs, + diffFileLoadingFor, + onWorktreeDiff, + onWorktreeDiffFile, + onWorktreeDiffLoading, + onWorktreeDiffNotice, + } +} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx index 953c9c26b9..44217e02e7 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/BaseBranchPicker.tsx @@ -96,7 +96,7 @@ export const BaseBranchPicker: Component = (props) => { placement="bottom-start" flip trigger={ - + } + > +
+ + {(opt, index) => ( + <> + {(text) =>
{text()}
}
+ + + )} +
+
+ + ) +} + +/** Compact unified/split picker. Replaces the wide radio group in tight rows. */ +export const DiffStyleSelect: Component<{ + value: "unified" | "split" + onSelect: (value: "unified" | "split") => void + unifiedLabel: string + splitLabel: string + title: string +}> = (props) => ( + +) diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx index e4e079638f..4a64ce3c7e 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/context/language.tsx @@ -27,6 +27,7 @@ import { dict as tr } from "../i18n/tr" import { dict as zh } from "../i18n/zh" import { dict as uk } from "../i18n/uk" import { dict as zht } from "../i18n/zht" +import { dict as fa } from "../i18n/fa" const dicts: Record> = { en, @@ -49,6 +50,7 @@ const dicts: Record> = { uk: { ...en, ...uk }, zh: { ...en, ...zh }, zht: { ...en, ...zht }, + fa: { ...en, ...fa }, } type LanguageCtx = { diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts b/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts new file mode 100644 index 0000000000..1c63633715 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/kiloclaw/i18n/fa.ts @@ -0,0 +1,95 @@ +export const dict = { + // App + "kiloClaw.loading": "در حال بارگذاری KiloClaw...", + + // Chat + "kiloClaw.chat.connecting": "در حال اتصال...", + "kiloClaw.chat.notRunning": "نمونه در حال اجرا نیست", + "kiloClaw.chat.placeholder": "پیامی بنویسید... (Enter برای ارسال)", + "kiloClaw.chat.online": "آنلاین", + "kiloClaw.chat.offline": "آفلاین", + "kiloClaw.chat.idle": "بیکار", + "kiloClaw.chat.unknown": "نامشخص", + "kiloClaw.chat.empty": "هنوز پیامی وجود ندارد. سلام کنید!", + "kiloClaw.chat.emptyWithBot": "هنوز پیامی وجود ندارد. به {bot} سلام کنید!", + "kiloClaw.chat.send": "ارسال", + "kiloClaw.chat.waitingBotStatus": "در انتظار وضعیت ربات...", + "kiloClaw.chat.botOffline": "ربات آفلاین است", + + // Conversations + "kiloClaw.conversations.title": "مکالمات", + "kiloClaw.conversations.new": "مکالمه جدید", + "kiloClaw.conversations.empty": "هنوز مکالمه‌ای وجود ندارد", + "kiloClaw.conversations.untitled": "بدون عنوان", + "kiloClaw.conversations.rename": "تغییر نام", + "kiloClaw.conversations.leave": "خروج", + "kiloClaw.conversations.selectOne": "یک مکالمه را برای شروع گفتگو انتخاب کنید", + "kiloClaw.conversations.groupToday": "امروز", + "kiloClaw.conversations.groupYesterday": "دیروز", + "kiloClaw.conversations.groupThisWeek": "این هفته", + "kiloClaw.conversations.groupOlder": "قدیمی‌تر", + + // Typing + "kiloClaw.typing.one": "{name} در حال تایپ است...", + "kiloClaw.typing.many": "{count} نفر در حال تایپ هستند...", + + // Messages + "kiloClaw.message.bot": "KiloClaw", + "kiloClaw.message.you": "شما", + "kiloClaw.message.thinking": "در حال فکر کردن...", + "kiloClaw.message.deleted": "[پیام حذف شد]", + "kiloClaw.message.replyDeleted": "پیام اصلی حذف شد", + "kiloClaw.message.notDelivered": "ارسال نشد", + "kiloClaw.message.edited": "(ویرایش شده)", + "kiloClaw.message.reply": "پاسخ", + "kiloClaw.message.replyTo": "در حال پاسخ به", + "kiloClaw.message.cancelReply": "لغو پاسخ", + "kiloClaw.message.edit": "ویرایش", + "kiloClaw.message.delete": "حذف", + "kiloClaw.message.confirmDelete": "این پیام حذف شود؟", + "kiloClaw.message.cancel": "انصراف", + "kiloClaw.message.save": "ذخیره", + "kiloClaw.message.react": "واکنش", + "kiloClaw.message.removeReaction": "حذف واکنش", + "kiloClaw.message.copy": "کپی", + "kiloClaw.message.copied": "در کلیپ‌بورد کپی شد", + "kiloClaw.message.copyFailed": "کپی انجام نشد", + + // Error + "kiloClaw.error.retry": "تلاش مجدد", + + // Setup + "kiloClaw.setup.title": "KiloClaw", + "kiloClaw.setup.subtitle": "هوش مصنوعی شخصی برای زندگی روزمره", + "kiloClaw.setup.description1": + "KiloClaw یک هوش مصنوعی شخصی در اختیار شما قرار می‌دهد که ایمیل‌ها را می‌خواند، تقویم شما را مدیریت می‌کند، پروژه‌هایتان را رصد می‌کند و در Telegram، Slack — هر چیزی که از قبل استفاده می‌کنید — در دسترس است.", + "kiloClaw.setup.description2": + "نیازی به نصب برنامه نیست. رابط جدیدی برای یادگیری وجود ندارد. فقط مثل یک دوست برایش پیام بفرستید.", + "kiloClaw.setup.learnMore": "بیشتر بدانید", + "kiloClaw.setup.tryKiloClaw": "امتحان KiloClaw", + + // Upgrade + "kiloClaw.upgrade.title": "KiloClaw Chat نیاز به ارتقا دارد", + "kiloClaw.upgrade.description1": "این نمونه قبل از فعال‌سازی چت راه‌اندازی شده است.", + "kiloClaw.upgrade.description2.before": "از دکمه ", + "kiloClaw.upgrade.description2.bold": "ارتقا به آخرین نسخه", + "kiloClaw.upgrade.description2.after": " در داشبورد KiloClaw برای فعال‌سازی چت زنده با ربات خود استفاده کنید.", + "kiloClaw.upgrade.openDashboard": "باز کردن داشبورد", + + // Sidebar + "kiloClaw.sidebar.title": "KiloClaw", + "kiloClaw.sidebar.instance": "نمونه", + "kiloClaw.sidebar.unknown": "ناشناخته", + "kiloClaw.sidebar.bot": "ربات", + "kiloClaw.sidebar.botStatus": "وضعیت ربات", + "kiloClaw.sidebar.context": "زمینه", + "kiloClaw.sidebar.used": "استفاده‌شده", + "kiloClaw.sidebar.tokens": "توکن‌ها", + "kiloClaw.sidebar.model": "مدل", + "kiloClaw.sidebar.provider": "ارائه‌دهنده", + "kiloClaw.sidebar.details": "جزئیات", + "kiloClaw.sidebar.region": "منطقه", + "kiloClaw.sidebar.version": "نسخه", + "kiloClaw.sidebar.channels": "کانال‌ها", + "kiloClaw.sidebar.noData": "داده‌ای برای نمونه وجود ندارد", +} diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 1aa6768823..7fa356e848 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -1,34 +1,18 @@ import { Component, createSignal, createMemo, Switch, Match, Show, onMount, onCleanup } from "solid-js" -import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" -import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" -import { FileComponentProvider } from "@kilocode/kilo-ui/context/file" -import { Code } from "@kilocode/kilo-ui/code" -import { Diff } from "@kilocode/kilo-ui/diff" -import { File } from "@kilocode/kilo-ui/file" import { DataProvider } from "@kilocode/kilo-ui/context/data" -import { Toast } from "@kilocode/kilo-ui/toast" import Settings from "./components/settings/Settings" import ProfileView from "./components/profile/ProfileView" -import { VSCodeProvider, useVSCode } from "./context/vscode" -import { ServerProvider, useServer } from "./context/server" -import { ProviderProvider, useProvider } from "./context/provider" -import { ConfigProvider } from "./context/config" -import { DisplayProvider } from "./context/display" +import { useVSCode } from "./context/vscode" +import { useServer } from "./context/server" +import { useProvider } from "./context/provider" import { WorkStyleProvider } from "./context/work-style" -import { IndexingProvider } from "./context/indexing" -import { AgentRequirementsProvider } from "./context/agent-requirements" -import { MemoryProvider } from "./context/memory" -import { SessionProvider, useSession } from "./context/session" +import { useSession } from "./context/session" import { LocalTabsProvider, useLocalTabs } from "./context/local-tabs" -import { LanguageBridge } from "./context/language-bridge" +import { ProviderShell } from "./context/provider-shell" import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" -import { SpeechToTextPrewarm } from "./components/speech-to-text/SpeechToTextPrewarm" // Override the upstream "task" tool renderer with the fully-expanded version // that shows child session parts inline in the VS Code sidebar. @@ -37,10 +21,6 @@ registerExpandedTaskTool() registerVscodeToolOverrides() import HistoryView from "./components/history/HistoryView" import { MigrationWizard } from "./components/migration" // legacy-migration -import { NotificationsProvider } from "./context/notifications" -import { FeedbackProvider } from "./context/feedback" -import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" -import { ImageModelsProvider } from "./context/image-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" import { cycleAgent as cycle } from "./context/session-agent" import "./styles/chat.css" @@ -210,27 +190,6 @@ export const DataBridge: Component<{ children: any }> = (props) => { ) } -type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> - -export const MermaidDownloadBridge: Component = () => { - const vscode = useVSCode() - - onMount(() => { - const save = (event: Event) => { - const detail = (event as MermaidImageEvent).detail - if (!detail?.dataUrl || !detail.filename) return - event.preventDefault() - vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename }) - } - window.addEventListener("kilo:save-image", save) - onCleanup(() => { - window.removeEventListener("kilo:save-image", save) - }) - }) - - return null -} - // Inner app component that uses the contexts const AppContent: Component = () => { const [currentView, setCurrentView] = createSignal("newTask") @@ -412,59 +371,21 @@ const AppContent: Component = () => { ) } -// Main App component with context providers const App: Component = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 40e0b72876..e5aa67b906 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -70,6 +70,8 @@ import { type TranscriptHold, type TranscriptRow, } from "../../context/transcript-rows" +import { PromptRail } from "./PromptRail" +import { capacity, historyAction, promptItems, railEntries, type PromptRailItem } from "./prompt-rail" import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight" import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search" import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight" @@ -164,6 +166,8 @@ export const MessageList: Component = (props) => { const [scrollEl, setScrollEl] = createSignal() const [virtualizer, setVirtualizer] = createSignal() const [layout, setLayout] = createSignal("") + // Transcript height, kept reactive so the prompt rail re-caps on resize. + const [height, setHeight] = createSignal(0) const revert = () => session.revert() ?? undefined const turns = createMemo((prev: MessageTurn[] | undefined) => @@ -905,8 +909,8 @@ export const MessageList: Component = (props) => { // entirely to the precise per-occurrence check in paintHighlights, // which only scrolls when the exact match actually needs it. if (!mounted) { - const index = keys().indexOf(match.key) - if (index >= 0) { + const index = indexes().get(match.key) + if (index !== undefined) { virtualizer()?.scrollToIndex(index, { align: "center" }) } } @@ -942,8 +946,65 @@ export const MessageList: Component = (props) => { const tail = createMemo(() => partition().direct.map((row) => row.key)) const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row]))) const keys = createMemo(() => partition().virtual.map((row) => row.key)) + const indexes = createMemo(() => new Map(keys().map((key, index) => [key, index]))) const fingerprint = createMemo(() => rowFingerprint(keys())) + const [pending, setPending] = createSignal<{ sid: string; key: string }>() + + // Scrolls the transcript to a row by key. Virtualized rows jump through + // the virtualizer; direct/live/queued rows are mounted, so they use + // scrollIntoView. Pauses auto-follow first so the jump isn't snapped back. + const jump = (key: string) => { + autoScroll.pause() + const index = indexes().get(key) + if (index !== undefined) { + const handle = virtualizer() + if (handle) { + setPending(undefined) + handle.scrollToIndex(index, { align: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) + return + } + const el = scrollEl() + const target = el?.querySelector(`[data-row-key="${CSS.escape(key)}"]`) + if (target) { + setPending(undefined) + target.scrollIntoView({ block: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) + } + + // Keep unresolved targets by stable row key. Virtual rows resolve once + // Virtua installs its handle; direct/live rows resolve once Solid mounts + // their DOM node. + createEffect(() => { + const target = pending() + if (!target) return + if (target.sid !== session.currentSessionID()) { + setPending(undefined) + return + } + const index = indexes().get(target.key) + const handle = virtualizer() + if (index !== undefined && handle) { + setPending(undefined) + autoScroll.pause() + handle.scrollToIndex(index, { align: "start" }) + return + } + const el = scrollEl() + const row = el?.querySelector(`[data-row-key="${CSS.escape(target.key)}"]`) + if (!row) return + setPending(undefined) + autoScroll.pause() + row.scrollIntoView({ block: "start" }) + }) + // Clicking a bar in the task timeline scrolls the transcript to that message. // Jumps land instantly (no smooth animation): while pinned at the bottom, a // smooth scroll's initial frames sit within createAutoScroll's near-bottom @@ -956,19 +1017,110 @@ export const MessageList: Component = (props) => { // actually contains the clicked part, not just the message's first chunk. const row = matches.find((r) => r.type === "assistant" && r.parts.some((p) => p.id === detail.partId)) ?? matches[0] if (!row) return - autoScroll.pause() - const index = keys().indexOf(row.key) - if (index >= 0) { - virtualizer()?.scrollToIndex(index, { align: "start" }) - return - } - const el = scrollEl() - const target = el?.querySelector(`[data-row-key="${CSS.escape(row.key)}"]`) - target?.scrollIntoView({ block: "start" }) + jump(row.key) } window.addEventListener("scrollToMessage", onScrollToMessage) onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage)) + // Prompt rail: one tick per user prompt, positioned to the left of the + // readable lane, opening a card of prompt/answer previews on hover. + const items = createMemo(() => promptItems(rows())) + // Until the transcript is measured there is no height to cap against, and + // rendering every prompt would spill ticks past the rail on long sessions. + const entries = createMemo(() => railEntries(items(), capacity(height()), session.hasOlderMessages())) + const [activeTurn, setActiveTurn] = createSignal() + const railActiveKey = createMemo(() => items().find((item) => item.turn === activeTurn())?.key) + + const [seek, setSeek] = createSignal<{ sid: string; count: number }>() + let paging = false + + const first = () => { + const item = items()[0] + if (!session.hasOlderMessages()) { + if (item) jump(item.key) + return + } + const sid = session.currentSessionID() + if (!sid || session.loadingOlderMessages()) return + setSeek({ sid, count: session.messages().length }) + if (!session.loadOlderMessages()) setSeek(undefined) + } + + // Loading the first prompt is deliberate and progressive: each completed + // prepend advances the existing page cursor, while hover/open remains free + // of network and full-history work. Stop if a request makes no progress so + // backend failures cannot turn into a retry loop. + createEffect(() => { + const loading = session.loadingOlderMessages() + const target = seek() + if (!target) { + paging = loading + return + } + if (target.sid !== session.currentSessionID()) { + paging = false + setSeek(undefined) + return + } + if (loading) { + paging = true + return + } + if (!paging) return + paging = false + const count = session.messages().length + const action = historyAction(target.count, count, session.hasOlderMessages()) + if (action === "stop") { + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + return + } + if (action === "load") { + setSeek({ sid: target.sid, count }) + if (!session.loadOlderMessages()) setSeek(undefined) + return + } + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + }) + + const trackActive = () => { + const list = items() + if (list.length === 0) return setActiveTurn(undefined) + const handle = virtualizer() + const offset = handle?.scrollOffset + if (handle && offset !== undefined && offset > 1) { + const row = partition().virtual[handle.findItemIndex(offset)] + if (row) return setActiveTurn(row.turn) + } + const el = scrollEl() + if (handle && el && el.scrollHeight > el.clientHeight + 1) { + const row = partition().virtual[0] + if (row) return setActiveTurn(row.turn) + } + setActiveTurn(list.at(-1)?.turn) + } + let activeFrame: number | undefined + const scheduleActive = () => { + if (activeFrame !== undefined) return + activeFrame = requestAnimationFrame(() => { + activeFrame = undefined + trackActive() + }) + } + onCleanup(() => { + if (activeFrame !== undefined) cancelAnimationFrame(activeFrame) + }) + // Re-derive the active turn whenever the transcript changes so the rail + // reflects a newly started turn even before any scrolling happens. + createEffect(() => { + items() + partition() + scheduleActive() + }) + // Highlights the part behind the currently hovered/focused timeline bar // (dispatched by TaskTimeline) so the two stay visually correlated. const [highlight, setHighlight] = createSignal() @@ -1018,6 +1170,7 @@ export const MessageList: Component = (props) => { const handleScroll = () => { autoScroll.handleScroll() maybeLoadOlder() + scheduleActive() if (search.active()) scheduleHighlight() } @@ -1026,6 +1179,7 @@ export const MessageList: Component = (props) => { const el = scrollEl() if (!el) return const style = getComputedStyle(el) + setHeight(el.clientHeight) setLayout( layoutFingerprint({ width: Math.round(el.clientWidth), @@ -1210,6 +1364,28 @@ export const MessageList: Component = (props) => {
+ railActiveKey()} + onSelect={(item: PromptRailItem) => jump(item.key)} + onFirst={first} + onLatest={() => { + const item = items().at(-1) + if (item) jump(item.key) + }} + onLoadOlder={() => session.loadOlderMessages()} + onWheel={(deltaY: number) => { + const el = scrollEl() + if (el) el.scrollTop += deltaY + }} + height={height} + hasOlder={session.hasOlderMessages} + loadingOlder={session.loadingOlderMessages} + prepending={() => session.messageMutation() === "prepend"} + seeking={() => Boolean(seek())} + /> + + ) + + return ( + = 2}> + + + + {(position) => ( + + + } + > + { + list = handle + }} + class="prompt-rail-list" + data={items()} + itemSize={ROW_HEIGHT} + bufferSize={ROW_HEIGHT * 3} + shift={props.prepending()} + onScroll={page} + onScrollEnd={() => { + revealing = false + }} + > + {row} + + +
+ + )} + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 736c6355a0..74fa8f1ba7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -348,7 +348,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
{summary()}
-
{questionText()}
+
+ {questionText()} +
e.stopPropagation()}> @@ -391,7 +393,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
-
{questionText()}
+
+ {questionText()} +
{language.t("ui.question.singleHint")}
}>
{language.t("ui.question.multiHint")}
@@ -419,9 +423,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => - {localized.label()} + + {localized.label()} + - {localized.description()} + + {localized.description()} + @@ -498,8 +506,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const answered = () => Boolean(value()) return (
- {tr(language.t, q.questionKey, q.question)} - + + {tr(language.t, q.questionKey, q.question)} + + {answered() ? value() : language.t("ui.question.review.notAnswered")}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx index 24abafa27b..7471b99610 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx @@ -42,7 +42,7 @@ export function SessionMentionPicker(props: Props) { items={props.sessions} key={(item) => item.id} - filterKeys={["title"]} + filterKeys={["title", "worktreeName"]} search={{ placeholder: "Search sessions", autofocus: true }} onSelect={(item) => { if (item) props.onSelect(item) @@ -52,6 +52,7 @@ export function SessionMentionPicker(props: Props) { {item.title} + {item.worktreeName && {item.worktreeName}} {formatRelativeDate(new Date(item.updated).toISOString())} )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx index 2eac797698..63cc677ad6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx @@ -30,7 +30,9 @@ export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => - {props.request.text} + + {props.request.text} +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx index 37c650a228..bd86ada3d5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx @@ -27,13 +27,13 @@ export const ConstrainDragYAxis: Component = () => { return null } -export const SortableTabContainer: ParentComponent<{ id: string }> = (props) => { +export const SortableTabContainer: ParentComponent<{ id: string; class?: string }> = (props) => { const sortable = createSortable(props.id) void sortable return (
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts new file mode 100644 index 0000000000..b18b76da44 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts @@ -0,0 +1,144 @@ +import type { Part } from "../../types/messages" +import type { TranscriptRow } from "../../context/transcript-rows" + +export interface PromptRailItem { + key: string + turn: string + queued: boolean + prompt: string + answer: string +} + +export type PromptRailEntry = + | { type: "prompt"; item: PromptRailItem; index: number } + | { type: "overflow"; count: number; index: number } + | { type: "history" } + +const PROMPT_LIMIT = 160 +const ANSWER_LIMIT = 220 + +/** + * Height of the tallest card row (padding + a one-line prompt + a two-line + * answer). Sizes the navigator's virtualized rows; the rail's own fit cap is + * measured in tick spacing instead, since a tick is only a hairline. + */ +export const ROW_HEIGHT = 76 +/** Vertical padding reserved at the top and bottom of the rail. */ +export const RAIL_INSET = 24 +/** Natural spacing between ticks, and the tightest they are allowed to pack. */ +export const TICK_STEP = 14 +export const TICK_MIN = 7 + +/** + * How many ticks fit the available transcript height. Measured in tick + * spacing, not card row height: a tick is a 1.5px line, so the rail holds + * several times more prompts than the navigator can list at once, and + * summarizing at the card's row count would hide prompts that have room to + * show. The complete prompt list lives in the bounded navigator. + */ +export function capacity(height: number): number { + return Math.floor((height - RAIL_INSET) / TICK_MIN) +} + +export function historyAction(before: number, after: number, more: boolean): "stop" | "load" | "jump" { + if (after <= before) return "stop" + return more ? "load" : "jump" +} + +// The card never renders markdown — user message text shows literally, and +// assistant text should too. Code spans (inline and fenced) are dropped, and +// link URLs / images are stripped rather than parsed (mirrors MessageList's +// stripMarkdownLinkUrls split so bracket text inside inline code stays out, +// not half-stripped). +export function previewText(raw: string): string { + const segments = raw.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + const text = segments.map((segment, i) => (i % 2 === 1 ? "" : stripLinks(segment))).join(" ") + return text + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*>+]\s+/gm, "") + .replace(/\s+/g, " ") + .trim() +} + +function stripLinks(text: string) { + return text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") +} + +function text(parts: Part[], limit: number): string { + const joined = parts + .filter((part) => part.type === "text" && !part.synthetic && part.text.trim()) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n") + return truncate(previewText(joined), limit) +} + +function truncate(value: string, limit: number) { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…` +} + +export function promptItems(rows: TranscriptRow[]): PromptRailItem[] { + const items: PromptRailItem[] = [] + for (const row of rows) { + if (row.type !== "user") continue + items.push({ key: row.key, turn: row.turn, queued: row.queued, prompt: text(row.parts, PROMPT_LIMIT), answer: "" }) + } + // Answer text is grouped by turn so one pass fills every item; assistant + // rows follow their user row and carry the same `turn` id. + let index = 0 + for (const row of rows) { + if (row.type === "user") { + index += 1 + continue + } + if (row.type !== "assistant" || index === 0) continue + const item = items[index - 1]! + if (item.turn !== row.turn || item.answer) continue + const value = text(row.parts, ANSWER_LIMIT) + if (value) item.answer = value + } + // A prompt can carry no text at all (image-only or file-only message). Rather + // than render a blank row, promote the answer into the label so the row still + // says something; if neither has text the card falls back to its placeholder. + for (const item of items) { + if (item.prompt || !item.answer) continue + item.prompt = truncate(item.answer, PROMPT_LIMIT) + item.answer = "" + } + return items +} + +export function railEntries(items: PromptRailItem[], capacity: number, history = false): PromptRailEntry[] { + if (capacity < 1) return [] + if (!history && items.length <= capacity) { + return items.map((item, index) => ({ type: "prompt", item, index })) + } + if (capacity === 1) { + if (history) return [{ type: "history" }] + const index = items.length - 1 + const item = items[index] + return item ? [{ type: "prompt", item, index }] : [] + } + if (capacity === 2) { + const item = items.at(-1) + const latest = item ? [{ type: "prompt" as const, item, index: items.length - 1 }] : [] + if (history) return [{ type: "history" }, ...latest] + const first = items[0] + return first ? [{ type: "prompt", item: first, index: 0 }, ...latest] : latest + } + + const count = Math.min(items.length, capacity - 2) + const start = items.length - count + const recent = items.slice(start).map((item, offset) => ({ + type: "prompt" as const, + item, + index: start + offset, + })) + const prefix: PromptRailEntry[] = history + ? [{ type: "history" }] + : items[0] + ? [{ type: "prompt", item: items[0], index: 0 }] + : [] + const hidden = start - (history ? 0 : 1) + if (hidden < 1) return [...prefix, ...recent] + return [...prefix, { type: "overflow", count: hidden, index: history ? 0 : 1 }, ...recent] +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 380f33efd9..508e835fe6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -17,7 +17,7 @@ import ModeEditView from "./ModeEditView" import ModeCreateView from "./ModeCreateView" import McpEditView from "./McpEditView" import WorkflowsTab from "./agent-behaviour/WorkflowsTab" -import { selectedDefaultAgentValue } from "./agent-behaviour-patches" +import { mcpConfigScope, mcpEnabledPatch, selectedDefaultAgentValue } from "./agent-behaviour-patches" import { parseImport, MAX_IMPORT_SIZE } from "./mode-io" import type { ImportError } from "./mode-io" @@ -50,7 +50,7 @@ type AgentView = "list" | "create" | "edit" const AgentBehaviourTab: Component = () => { const language = useLanguage() - const { config, updateConfig } = useConfig() + const { config, collections, updateConfig, updateGlobalConfig, updateProjectConfig } = useConfig() const session = useSession() const dialog = useDialog() const vscode = useVSCode() @@ -675,12 +675,17 @@ const AgentBehaviourTab: Component = () => { { - if (isConnected(name)) { - session.disconnectMcp(name) - } else { - session.connectMcp(name) + onChange={(enabled: boolean) => { + const scope = mcpConfigScope(name, collections()) + if (scope) { + const update = scope === "project" ? updateProjectConfig : updateGlobalConfig + update(mcpEnabledPatch(name, enabled)) } + if (!enabled) { + session.disconnectMcp(name) + return + } + session.connectMcp(name) }} hideLabel > diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx index 876a230f65..eee016f3f5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/BrowserTab.tsx @@ -1,14 +1,32 @@ -import { Component, createSignal, onCleanup, onMount } from "solid-js" +import { Component, Show, createSignal, onCleanup, onMount } from "solid-js" import { Switch } from "@kilocode/kilo-ui/switch" import { Card } from "@kilocode/kilo-ui/card" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" +import { useConfig } from "../../context/config" import type { BrowserSettings } from "../../types/messages" import SettingsRow from "./SettingsRow" +const Header: Component<{ title: string }> = (props) => ( +

+ {props.title} +

+) + const BrowserTab: Component = () => { const { postMessage, onMessage } = useVSCode() const { t } = useLanguage() + const { globalConfig, projectConfig, updateGlobalConfig } = useConfig() const [settings, setSettings] = createSignal({ enabled: false, @@ -33,6 +51,12 @@ const BrowserTab: Component = () => { postMessage({ type: "updateSetting", key: `browserAutomation.${key}`, value }) } + const updateWebsearch = (checked: boolean) => { + updateGlobalConfig({ web_search: checked }) + } + + const overridden = () => projectConfig().web_search !== undefined + return (
{/* Info text */} @@ -52,43 +76,90 @@ const BrowserTab: Component = () => { "line-height": "1.5", }} > - {t("settings.browser.description")} + {t("settings.webTools.description")}

- - {/* Enable toggle */} - - update("enabled", checked)} hideLabel> - {t("settings.browser.enable.title")} - - - - {/* Use System Chrome */} - - update("useSystemChrome", checked)} - hideLabel +
+
+ + t("settings.config.scope.global")} + last={!overridden()} > - {t("settings.browser.systemChrome.title")} - - + + {t("settings.webTools.webSearch.title")} + + + + t("settings.config.scope.local")} + last + > + + {`${t("settings.webTools.webSearch.title")} (${t("settings.config.scope.local")})`} + + + + +
- {/* Headless mode */} - +
+

- update("headless", checked)} hideLabel> - {t("settings.browser.headless.title")} - - - + {t("settings.browser.description")} +

+ + {/* Enable toggle */} + + update("enabled", checked)} hideLabel> + {t("settings.browser.enable.title")} + + + + {/* Use System Chrome */} + + update("useSystemChrome", checked)} + hideLabel + > + {t("settings.browser.systemChrome.title")} + + + + {/* Headless mode */} + + update("headless", checked)} + hideLabel + > + {t("settings.browser.headless.title")} + + + +
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index cbd400f47b..badc9a87b5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -24,7 +24,7 @@ const SHARE_OPTIONS: ShareOption[] = [ ] const ExperimentalTab: Component = () => { - const { config, updateConfig } = useConfig() + const { config, settings, updateConfig, applySetting } = useConfig() const language = useLanguage() const imageModels = useImageModels() const vscode = useVSCode() @@ -251,6 +251,19 @@ const ExperimentalTab: Component = () => {
+ + applySetting("multiProject", checked, "experimental.multiProject")} + hideLabel + > + {language.t("settings.experimental.multiProject.title")} + + + {/* MCP timeout */} string + tag: () => string | undefined + onScope: (next: IndexingScope) => void + onEnabled: (next: boolean) => void +}> = (props) => { + const scope = () => props.scope + const language = { t: props.t } + const enabled = () => props.enabled + const inherited = () => props.inherited + const changeScope = props.onScope + const saveEnabled = props.onEnabled + const tag = props.tag + return ( + <> + +
+ + +
+
+ + + {scope() === "global" + ? language.t("settings.indexing.globalEnable.title") + : language.t("settings.indexing.projectEnable.title")} + + + + ) +} + const IndexingTab: Component = () => { const { globalConfig, projectConfig, settings, updateGlobalConfig, updateProjectConfig, updateSetting } = useConfig() const indexing = useIndexing() @@ -99,6 +168,7 @@ const IndexingTab: Component = () => { const language = useLanguage() const provider = useProvider() const server = useServer() + const vscode = useVSCode() const [providerDrafts, setProviderDrafts] = createSignal>({}) const [storeDrafts, setStoreDrafts] = createSignal>({}) const [tuningDrafts, setTuningDrafts] = createSignal>({}) @@ -112,6 +182,10 @@ const IndexingTab: Component = () => { const cfg = createMemo(() => indexingConfig(scope(), globalCfg(), projectCfg())) const enabled = createMemo(() => indexingEnabled(scope(), globalCfg(), projectCfg())) const inherited = createMemo(() => indexingEnabledInherited(scope(), globalCfg(), projectCfg())) + const consent = createMemo(() => settings()["indexing.consent"] === true) + const projects = createMemo(() => (settings()["indexing.projects"] as Project[] | undefined) ?? []) + const projectId = createMemo(() => settings()["indexing.projectId"] as string | undefined) + const project = createMemo(() => projects().find((item) => item.id === projectId())) const inheritance = (paths: readonly (readonly string[])[]) => indexingInheritance(scope(), globalCfg(), projectCfg(), paths) const tag = (current: IndexingScope, paths: readonly (readonly string[])[]) => @@ -119,6 +193,7 @@ const IndexingTab: Component = () => { const description = (value: string, paths: readonly (readonly string[])[]) => indexingDescription(value, inheritance(paths)) const changeScope = (next: IndexingScope) => { + // Blur first so a pending field edit commits against the scope it was typed in. const active = document.activeElement if (active instanceof HTMLElement) active.blur() setScope(next) @@ -166,17 +241,23 @@ const IndexingTab: Component = () => { updateIndexing({ provider: next, model: null, dimension: null }) } - const saveEnabled = (enabled: boolean) => { - if (enabled && !cfg().provider && kiloAvailable()) { + /** Machine-local consent for the selected project; never written to config. */ + const saveConsent = (granted: boolean) => { + const id = projectId() + if (id) vscode.postMessage({ type: "setIndexingConsent", projectId: id, enabled: granted }) + } + + const saveEnabled = (next: boolean) => { + if (next && !cfg().provider && kiloAvailable()) { updateIndexing({ - enabled, + enabled: next, provider: "kilo", model: knownKiloModel(cfg().model) ?? (kiloDefault() || null), dimension: null, }) return } - updateIndexing({ enabled }) + updateIndexing({ enabled: next }) } const saveModel = (value: string) => { @@ -276,53 +357,41 @@ const IndexingTab: Component = () => { const content = (_scope: IndexingScope) => (
+ +