diff --git a/.changeset/command-model-selector-sync.md b/.changeset/command-model-selector-sync.md new file mode 100644 index 0000000000..cd64ab6f90 --- /dev/null +++ b/.changeset/command-model-selector-sync.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Update the active model, mode, and thinking selectors when executing a slash command with configured overrides. diff --git a/.changeset/fix-agent-manager-terminal-toggle.md b/.changeset/fix-agent-manager-terminal-toggle.md new file mode 100644 index 0000000000..a93825d595 --- /dev/null +++ b/.changeset/fix-agent-manager-terminal-toggle.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the Agent Manager terminal toolbar button to toggle panel visibility directly. diff --git a/.changeset/fix-multi-project-agent-manager-tool.md b/.changeset/fix-multi-project-agent-manager-tool.md new file mode 100644 index 0000000000..bb4e6304f6 --- /dev/null +++ b/.changeset/fix-multi-project-agent-manager-tool.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Route Agent Manager tool-launched sessions to the project that owns the tool event directory, keep sandboxed worktree sessions inside their active worktree, and wait for busy managed sessions before prompting them. diff --git a/.changeset/fix-secondary-sidebar-nav-bar.md b/.changeset/fix-secondary-sidebar-nav-bar.md index 070b89d5f8..7905945a1e 100644 --- a/.changeset/fix-secondary-sidebar-nav-bar.md +++ b/.changeset/fix-secondary-sidebar-nav-bar.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Fix the sidebar navigation bar (New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, Settings) disappearing when the Kilo Code view is docked in the Secondary Side Bar. The navigation is now rendered inside the webview itself, so it stays visible regardless of where the view is docked. +Fix the sidebar navigation bar (New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, Settings) disappearing in Cursor when the Kilo Code view is docked in the Secondary Side Bar. Cursor now renders the navigation inside the webview itself so it stays visible regardless of dock location. VS Code is unaffected — it continues to use its native title bar toolbar, which already worked correctly everywhere. diff --git a/.changeset/fix-tool-approval-source-display.md b/.changeset/fix-tool-approval-source-display.md new file mode 100644 index 0000000000..3308ed0827 --- /dev/null +++ b/.changeset/fix-tool-approval-source-display.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Move the "why was this tool call approved" line to after the tool output instead of between the header and body, add an icon to it, and add a Display setting to hide it. diff --git a/.changeset/narrow-agent-manager-terminal.md b/.changeset/narrow-agent-manager-terminal.md new file mode 100644 index 0000000000..5091714bdb --- /dev/null +++ b/.changeset/narrow-agent-manager-terminal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager terminals aligned and correctly wrapped when the terminal panel is narrow. diff --git a/.changeset/narrow-sidebar-recent.md b/.changeset/narrow-sidebar-recent.md new file mode 100644 index 0000000000..fcd56b0f73 --- /dev/null +++ b/.changeset/narrow-sidebar-recent.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep recent sessions and the Show History action inset and usable in narrow VS Code sidebars. diff --git a/.changeset/optimize-review-slash-commands.md b/.changeset/optimize-review-slash-commands.md new file mode 100644 index 0000000000..8e104765c2 --- /dev/null +++ b/.changeset/optimize-review-slash-commands.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +--- + +Add nested slash command suggestions for `/review` in VS Code and support `staged`, `unpushed`, and `quick` review modes. diff --git a/.changeset/sandbox-live-settings.md b/.changeset/sandbox-live-settings.md new file mode 100644 index 0000000000..f356683b99 --- /dev/null +++ b/.changeset/sandbox-live-settings.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Apply saved sandbox settings to existing sessions and use the latest settings when enabling sandboxing diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index f494bbb4c8..51d40efb26 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -41,6 +41,9 @@ jobs: - name: Check Effect Promise facade allowlist run: bun run script/check-opencode-promise-facades.ts + - name: Check domain architecture boundaries and ratchets + run: bun run script/check-architecture.ts + - name: Check model tool network boundary run: bun run script/check-model-tool-network.ts diff --git a/bun.lock b/bun.lock index a4b647e0ba..44e8e4b611 100644 --- a/bun.lock +++ b/bun.lock @@ -484,6 +484,8 @@ }, "devDependencies": { "@axe-core/playwright": "4.11.3", + "@babel/core": "^7.28.4", + "@babel/preset-typescript": "^7.27.1", "@playwright/test": "1.57.0", "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", @@ -495,6 +497,7 @@ "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.7.1", + "babel-preset-solid": "^1.9.9", "esbuild": "^0.27.2", "esbuild-plugin-solid": "^0.6.0", "eslint": "^9.39.2", @@ -997,23 +1000,23 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", "web-tree-sitter", + "esbuild", "tree-sitter-bash", + "protobufjs", ], "patchedDependencies": { - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", @@ -5460,10 +5463,6 @@ "@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - "@storybook/addon-links/storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="], "@storybook/addon-onboarding/storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="], @@ -6182,14 +6181,6 @@ "@smithy/util-stream/@smithy/core/@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-json/effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "@storybook/addon-links/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@storybook/addon-links/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], diff --git a/nix/hashes.json b/nix/hashes.json index 73541a476d..52d88513d8 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-epitmtKUd9fAucKdH/sDdqv5WmwfpiPH+h/PNt55gd4=", - "aarch64-linux": "sha256-FHVsi2iho+U5aj6Z9lg2GTIu1ViUuqI2c/nZuwjmPH8=", - "aarch64-darwin": "sha256-QDPVLcbTyaZRZvSnqvWNR/mIkS1TrZLX0HjUlg6xwwI=", - "x86_64-darwin": "sha256-SSPc9b3WwcYCnuywcyMRJjVlugoXuo0UoPJ+qzPGajk=" + "x86_64-linux": "sha256-y6PZR6BsVZcsK/qJ0XvJBevukYzVJ8NRLHRbNSuSzb8=", + "aarch64-linux": "sha256-0ozGLgGTHajlHqofixtjJv/dggq25MhWYIqmfmb+6Z0=", + "aarch64-darwin": "sha256-IEYJothLBDT20BmLamBqM+pciFMQtWy8Um1YjFjbKz4=", + "x86_64-darwin": "sha256-k2bQGKTkvIZmB2+I2Fy3TPm/NZ0BbGjxp9esxtGuUbI=" } } diff --git a/package.json b/package.json index 07fe060f00..da6ead2edb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", + "check:architecture": "bun run script/check-architecture.ts", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty && bun run script/setup-git.ts", diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 024c978280..5eac8611d0 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -531,6 +531,7 @@ type Endpoint14_1Input = { readonly cwd?: Endpoint14_1Request["payload"]["cwd"] readonly title?: Endpoint14_1Request["payload"]["title"] readonly env?: Endpoint14_1Request["payload"]["env"] + readonly size?: Endpoint14_1Request["payload"]["size"] } const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) => raw["pty.create"]({ @@ -541,6 +542,7 @@ const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Inpu cwd: input?.["cwd"], title: input?.["title"], env: input?.["env"], + size: input?.["size"], }, }).pipe(Effect.mapError(mapClientError)) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 5f6943c53e..b17a7ebd9f 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -840,6 +840,7 @@ export function make(options: ClientOptions) { cwd: input?.["cwd"], title: input?.["title"], env: input?.["env"], + size: input?.["size"], }, successStatus: 200, declaredStatuses: [400, 401], diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 9d91878667..a96ecb5d5e 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2632,6 +2632,7 @@ export type PtysCreateInput = { readonly cwd?: string readonly title?: string readonly env?: { readonly [x: string]: string } + readonly size?: { readonly rows: number; readonly cols: number } }["command"] readonly args?: { readonly command?: string @@ -2639,6 +2640,7 @@ export type PtysCreateInput = { readonly cwd?: string readonly title?: string readonly env?: { readonly [x: string]: string } + readonly size?: { readonly rows: number; readonly cols: number } }["args"] readonly cwd?: { readonly command?: string @@ -2646,6 +2648,7 @@ export type PtysCreateInput = { readonly cwd?: string readonly title?: string readonly env?: { readonly [x: string]: string } + readonly size?: { readonly rows: number; readonly cols: number } }["cwd"] readonly title?: { readonly command?: string @@ -2653,6 +2656,7 @@ export type PtysCreateInput = { readonly cwd?: string readonly title?: string readonly env?: { readonly [x: string]: string } + readonly size?: { readonly rows: number; readonly cols: number } }["title"] readonly env?: { readonly command?: string @@ -2660,7 +2664,16 @@ export type PtysCreateInput = { readonly cwd?: string readonly title?: string readonly env?: { readonly [x: string]: string } + readonly size?: { readonly rows: number; readonly cols: number } }["env"] + readonly size?: { + readonly command?: string + readonly args?: ReadonlyArray + readonly cwd?: string + readonly title?: string + readonly env?: { readonly [x: string]: string } + readonly size?: { readonly rows: number; readonly cols: number } + }["size"] } export type PtysCreateOutput = { diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 44d95cb508..97f41bcd54 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -214,7 +214,17 @@ const layer = Layer.effect( } yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd }) const { spawn } = yield* Effect.promise(() => pty()) - const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env })) + // kilocode_change start - spawn with initial terminal dimensions + const proc = yield* Effect.sync(() => + spawn(command, args, { + name: "xterm-256color", + cwd, + env, + cols: input.size?.cols, + rows: input.size?.rows, + }), + ) + // kilocode_change end const info: Info = { id, title: input.title || `Terminal ${id.slice(-4)}`, diff --git a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md index 72f1029bc4..462a582ab7 100644 --- a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md +++ b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md @@ -161,9 +161,9 @@ A configured destination is an egress route, not tenant, organization, repositor The config setting supplies the initial default for new sessions that do not have a saved preference. Use the lock button in the VS Code prompt or `/sandbox` in the CLI to change the current session. Your latest choice is saved as the default for future sessions in that project, takes precedence over the config default, and persists across restarts. -Each initialized session snapshots its network mode, allowed destinations, and additional writable paths. Changing config affects new sessions. The prompt control or `/sandbox` can change the current session's enabled state, but it cannot change these authority lists, and they never expand during an active session. +Each session preserves its enabled or disabled choice. Saving changes through Kilo settings to network mode, allowed destinations, or additional writable paths refreshes existing session policies before their next tool execution. Enabling sandboxing also reads the latest settings. A tool that is already running keeps the policy it started with. -Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. +At creation, forked sessions retain the source session's confinement and subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. Later trusted sandbox settings replace those network and writable-path limits before the affected session's next tool execution. Cloud sessions do not expose the local sandbox control because their tools do not run in your local sandbox. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/sidebar-top-bar-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/sidebar-top-bar-default-chromium-linux.png index d5298111b8..a3d1308725 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/sidebar-top-bar-default-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/sidebar-top-bar-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bc2a28b80d8dc5aea4c03609efc13a23a2c88103e56df741155d9e794fc96f7 -size 2422 +oid sha256:d49870048a42ea9c0166143fbb364b0d9ee45d786e3b1346fcd0eda76c533bdb +size 2414 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png index 2597a60c84..13b431ad97 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3f86dabc39700b3a86508c1e6499a546eeabf720832b62c9ce12f2d97378ebd -size 34649 +oid sha256:00092414080e2f70fb9b3166e8d06a1692af93e33717d0ab8fea5fe96e6a6982 +size 34468 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index f6b84e3d29..e8471869de 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -72,6 +72,8 @@ - +- + - - diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index a386323f99..9d7ff5acc7 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -507,21 +507,25 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty } } -/* "why was this allowed" line inside a tool's expanded body */ +/* "why was this allowed" line inside a tool's expanded body. Styled like + [data-component="tool-hint"] (muted, italic) so it reads as ambient + context rather than a call to action, and recedes the way reasoning text does. */ [data-slot="tool-approval-line"] { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: 4px; padding: 4px 0 6px; font-family: var(--font-family-sans); font-size: var(--font-size-small); + font-style: italic; line-height: var(--line-height-normal); color: var(--text-weak); + opacity: 0.9; - [data-slot="tool-approval-decision"] { - font-weight: var(--font-weight-medium); - color: var(--text-strong); + svg { + flex-shrink: 0; + color: var(--text-weak); } [data-slot="tool-approval-rule"] { diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index 7dbaeee939..ac225cd73b 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -44,18 +44,24 @@ export function BasicTool(props: BasicToolProps) { writeToolOpen(key(), open) props.onOpenChange?.(open) } + // Renders after the body/tool list, not before — it's context about what + // happened, not part of the header. const details = () => (
- {(value) => } {props.children} + {(value) => }
) - if (!("children" in props) && !inBody()) { - return - } + // A , not a plain `if`: inBody() tracks the visibility toggle, which can + // flip after mount (Settings), so the branch must stay reactive. return ( - - {details()} - + } + > + + {details()} + + ) } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 4281174d12..bce8f1c891 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -49,7 +49,7 @@ import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval" -export { ToolApprovalProvider, resolveToolApproval } from "./tool-approval" +export { ToolApprovalProvider, resolveToolApproval, ToolApprovalVisibilityProvider } from "./tool-approval" import { GrowBox } from "./grow-box" import { COLLAPSIBLE_SPRING } from "./motion" import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx index d555e105df..9fc6e10269 100644 --- a/packages/kilo-ui/src/components/tool-approval.tsx +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js" +import { Icon } from "./icon" /** * Explains why a tool call was auto-approved, inside the expanded tool row. @@ -30,8 +31,23 @@ export function ToolApprovalProvider(props: ParentProps<{ value: Accessor{props.children} } +/** + * Whether the approval line should render at all. Hosts that expose a "hide + * auto-approval reason" display setting wrap their tree in + * `ToolApprovalVisibilityProvider`; without one, the line stays visible. + */ +const VisibilityContext = createContext>(() => true) + +export function ToolApprovalVisibilityProvider(props: ParentProps<{ value: Accessor }>) { + return {props.children} +} + +/** Read the approval for the tool row below, gated by the visibility toggle + * here (not per call site) so a new `ToolApprovalProvider` usage can't forget it. */ export function useToolApproval() { - return useContext(Context) + const value = useContext(Context) + const visible = useContext(VisibilityContext) + return () => (visible() ? value() : undefined) } /** Read the raw approval payload off a tool part's metadata, if present. */ @@ -79,6 +95,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) { const manual = () => props.display.approval.source === "manual" return (
+ {props.display.decision} {(text) => {text()}} diff --git a/packages/kilo-ui/src/lucide.ts b/packages/kilo-ui/src/lucide.ts index e715ec841d..ec7ab9c17f 100644 --- a/packages/kilo-ui/src/lucide.ts +++ b/packages/kilo-ui/src/lucide.ts @@ -3,4 +3,4 @@ * Only add icons here that are actually used — esbuild/Vite will * tree-shake unused exports but explicit re-exports keep the API small. */ -export { WandSparkles } from "lucide-solid" +export { default as WandSparkles } from "lucide-solid/icons/wand-sparkles" diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index eed8e2475b..2fa1876afd 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -1,10 +1,96 @@ const esbuild = require("esbuild") const path = require("path") -const { solidPlugin } = require("esbuild-plugin-solid") +const fs = require("fs") +const crypto = require("crypto") +const core = require("@babel/core") +const solid = require("babel-preset-solid") +const ts = require("@babel/preset-typescript") const production = process.argv.includes("--production") const watch = process.argv.includes("--watch") +/** + * Cache transformed Solid JSX files in memory and on disk to avoid + * re-parsing and re-transforming unchanged files across builds and webviews. + */ +const solidCacheDir = path.join(__dirname, "node_modules", ".cache", "esbuild-solid") +const solidMemCache = new Map() +const buildScriptHash = crypto + .createHash("sha256") + .update(fs.readFileSync(__filename, "utf8")) + .update(require("babel-preset-solid/package.json").version || "") + .update(require("@babel/preset-typescript/package.json").version || "") + .digest("hex") + .slice(0, 8) + +if (!fs.existsSync(solidCacheDir)) { + try { + fs.mkdirSync(solidCacheDir, { recursive: true }) + } catch (err) { + console.warn("[esbuild] could not create solid cache directory", err) + } +} + +const cachedSolidPlugin = { + name: "esbuild:solid-cached", + setup(build) { + build.onLoad({ filter: /\.(t|j)sx$/ }, async (args) => { + let mtime = 0 + let size = 0 + try { + const st = fs.statSync(args.path) + mtime = st.mtimeMs + size = st.size + } catch (err) { + console.warn("[esbuild] could not stat source file for cache key", args.path, err) + } + + const cacheKey = `${args.path}:${mtime}:${size}:${buildScriptHash}` + const memHit = solidMemCache.get(cacheKey) + if (memHit) return { contents: memHit, loader: "js" } + + const diskKey = crypto.createHash("sha256").update(cacheKey).digest("hex") + ".js" + const diskPath = path.join(solidCacheDir, diskKey) + + if (fs.existsSync(diskPath)) { + try { + const diskCode = fs.readFileSync(diskPath, "utf8") + solidMemCache.set(cacheKey, diskCode) + return { contents: diskCode, loader: "js" } + } catch (err) { + console.warn("[esbuild] cache read failed, rebuilding", diskPath, err) + } + } + + const source = fs.readFileSync(args.path, "utf8") + const { name, ext } = path.parse(args.path) + const filename = name + ext + const result = await core.transformAsync(source, { + presets: [ + [solid, {}], + [ts, {}], + ], + filename, + sourceMaps: "inline", + }) + + if (result?.code === void 0 || result.code === null) { + throw new Error("No result was provided from Babel") + } + + if (solidMemCache.size > 2000) solidMemCache.clear() + solidMemCache.set(cacheKey, result.code) + try { + fs.writeFileSync(diskPath, result.code) + } catch (err) { + console.warn("[esbuild] cache write failed", diskPath, err) + } + + return { contents: result.code, loader: "js" } + }) + }, +} + /** * Force all solid-js imports (from kilo-ui and the webview) to resolve to * the **same** copy so SolidJS contexts are shared across packages. @@ -123,7 +209,7 @@ const svgSpritePlugin = { name: "svg-sprite-inline", setup(build) { build.onLoad({ filter: /sprite\.svg$/ }, (args) => { - const content = require("fs").readFileSync(args.path, "utf8") + const content = fs.readFileSync(args.path, "utf8") return { contents: ` const svg = ${JSON.stringify(content)}; @@ -160,69 +246,8 @@ const cssPackageResolvePlugin = { }, } -function createBrowserWebviewContext(entryPoint, outfile) { - return esbuild.context({ - entryPoints: [entryPoint], - bundle: true, - format: "iife", - minify: production, - sourcemap: !production, - sourcesContent: false, - platform: "browser", - outfile, - logLevel: "silent", - loader: { - ".woff": "file", - ".woff2": "file", - ".ttf": "file", - }, - plugins: [ - solidDedupePlugin, - pierreWorkerAliasPlugin, - markdownWorkerUrlPlugin, - svgSpritePlugin, - cssPackageResolvePlugin, - solidPlugin(), - esbuildProblemMatcherPlugin, - ], - }) -} - -// Bundle Pierre's Shiki worker into a single self-contained asset that the -// webviews load off the main thread for syntax highlighting. -function createShikiWorkerContext() { - return esbuild.context({ - entryPoints: ["kilo-shiki-worker"], - bundle: true, - format: "iife", - minify: production, - sourcemap: !production, - sourcesContent: false, - platform: "browser", - outfile: "dist/shiki-worker.js", - logLevel: "silent", - plugins: [shikiWorkerEntryPlugin, esbuildProblemMatcherPlugin], - }) -} - -function createMarkdownShikiWorkerContext() { - return esbuild.context({ - entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")], - bundle: true, - format: "esm", - minify: production, - sourcemap: !production, - sourcesContent: false, - platform: "browser", - outfile: "dist/markdown-shiki-worker.js", - logLevel: "silent", - plugins: [esbuildProblemMatcherPlugin], - }) -} - -async function main() { - // Build extension - const extensionCtx = await esbuild.context({ +function getExtensionConfig() { + return { entryPoints: ["src/extension.ts"], bundle: true, format: "cjs", @@ -239,68 +264,101 @@ async function main() { outfile: "dist/extension.js", external: ["vscode"], logLevel: "silent", - plugins: [esbuildProblemMatcherPlugin], - }) + plugins: watch ? [esbuildProblemMatcherPlugin] : [], + } +} - // Build Agent Manager webview (SolidJS, shares components with sidebar) - const agentManagerCtx = await createBrowserWebviewContext( - "webview-ui/agent-manager/index.tsx", - "dist/agent-manager.js", - ) +function getWebviewsConfig() { + return { + entryPoints: { + "agent-manager": "webview-ui/agent-manager/index.tsx", + kiloclaw: "webview-ui/kiloclaw/index.tsx", + marketplace: "webview-ui/marketplace/index.tsx", + "diff-viewer": "webview-ui/diff-viewer/index.tsx", + "diff-virtual": "webview-ui/diff-virtual/index.tsx", + webview: "webview-ui/src/index.tsx", + }, + outdir: "dist", + bundle: true, + format: "iife", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + logLevel: "silent", + loader: { + ".woff": "file", + ".woff2": "file", + ".ttf": "file", + }, + plugins: [ + solidDedupePlugin, + pierreWorkerAliasPlugin, + markdownWorkerUrlPlugin, + svgSpritePlugin, + cssPackageResolvePlugin, + cachedSolidPlugin, + ...(watch ? [esbuildProblemMatcherPlugin] : []), + ], + } +} - // Build KiloClaw webview (SolidJS, standalone chat panel) - const kiloClawCtx = await createBrowserWebviewContext("webview-ui/kiloclaw/index.tsx", "dist/kiloclaw.js") +function getShikiWorkerConfig() { + return { + entryPoints: ["kilo-shiki-worker"], + bundle: true, + format: "iife", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + outfile: "dist/shiki-worker.js", + logLevel: "silent", + plugins: [shikiWorkerEntryPlugin, ...(watch ? [esbuildProblemMatcherPlugin] : [])], + } +} - // Build Marketplace webview (SolidJS, standalone catalog panel) - const marketplaceCtx = await createBrowserWebviewContext("webview-ui/marketplace/index.tsx", "dist/marketplace.js") +function getMarkdownShikiWorkerConfig() { + return { + entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")], + bundle: true, + format: "esm", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + outfile: "dist/markdown-shiki-worker.js", + logLevel: "silent", + plugins: watch ? [esbuildProblemMatcherPlugin] : [], + } +} - // Build Diff Viewer webview (SolidJS, reuses Agent Manager diff components) - const diffViewerCtx = await createBrowserWebviewContext("webview-ui/diff-viewer/index.tsx", "dist/diff-viewer.js") - - // Build Diff Virtual webview (lightweight single-file diff for permission approval) - const diffVirtualCtx = await createBrowserWebviewContext("webview-ui/diff-virtual/index.tsx", "dist/diff-virtual.js") - - // Build webview - const webviewCtx = await createBrowserWebviewContext("webview-ui/src/index.tsx", "dist/webview.js") - - // Build the shared Shiki highlighting worker asset - const shikiWorkerCtx = await createShikiWorkerContext() - const markdownShikiWorkerCtx = await createMarkdownShikiWorkerContext() +async function main() { + const extensionConfig = getExtensionConfig() + const webviewsConfig = getWebviewsConfig() + const shikiWorkerConfig = getShikiWorkerConfig() + const markdownShikiWorkerConfig = getMarkdownShikiWorkerConfig() if (watch) { + const [extensionCtx, webviewsCtx, shikiWorkerCtx, markdownShikiWorkerCtx] = await Promise.all([ + esbuild.context(extensionConfig), + esbuild.context(webviewsConfig), + esbuild.context(shikiWorkerConfig), + esbuild.context(markdownShikiWorkerConfig), + ]) + await Promise.all([ extensionCtx.watch(), - webviewCtx.watch(), - agentManagerCtx.watch(), - diffViewerCtx.watch(), - diffVirtualCtx.watch(), - kiloClawCtx.watch(), - marketplaceCtx.watch(), + webviewsCtx.watch(), shikiWorkerCtx.watch(), markdownShikiWorkerCtx.watch(), ]) } else { await Promise.all([ - extensionCtx.rebuild(), - webviewCtx.rebuild(), - agentManagerCtx.rebuild(), - kiloClawCtx.rebuild(), - marketplaceCtx.rebuild(), - diffViewerCtx.rebuild(), - diffVirtualCtx.rebuild(), - shikiWorkerCtx.rebuild(), - markdownShikiWorkerCtx.rebuild(), - ]) - await Promise.all([ - extensionCtx.dispose(), - webviewCtx.dispose(), - agentManagerCtx.dispose(), - diffViewerCtx.dispose(), - diffVirtualCtx.dispose(), - kiloClawCtx.dispose(), - marketplaceCtx.dispose(), - shikiWorkerCtx.dispose(), - markdownShikiWorkerCtx.dispose(), + esbuild.build(extensionConfig), + esbuild.build(webviewsConfig), + esbuild.build(shikiWorkerConfig), + esbuild.build(markdownShikiWorkerConfig), ]) } } diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index acca2e4b35..2721db62a0 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -147,6 +147,41 @@ "category": "Kilo Code", "icon": "$(settings-gear)" }, + { + "command": "kilo-code.new.sidebarTitle.plusButtonClicked", + "title": "New Task", + "icon": "$(add)" + }, + { + "command": "kilo-code.new.sidebarTitle.agentManagerOpen", + "title": "Agent Manager", + "icon": "$(organization)" + }, + { + "command": "kilo-code.new.sidebarTitle.kiloClawOpen", + "title": "KiloClaw", + "icon": "$(comment-discussion)" + }, + { + "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", + "title": "Marketplace", + "icon": "$(extensions)" + }, + { + "command": "kilo-code.new.sidebarTitle.historyButtonClicked", + "title": "History", + "icon": "$(history)" + }, + { + "command": "kilo-code.new.sidebarTitle.profileButtonClicked", + "title": "Profile", + "icon": "$(account)" + }, + { + "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", + "title": "Settings", + "icon": "$(settings-gear)" + }, { "command": "kilo-code.new.openInTab", "title": "Open in Tab", @@ -442,12 +477,76 @@ ], "menus": { "commandPalette": [ + { + "command": "kilo-code.new.sidebarTitle.plusButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.historyButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.agentManagerOpen", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.kiloClawOpen", + "when": "false" + }, { "command": "kilo-code.new.agentManager.advancedWorktree", "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.profileButtonClicked", + "when": "false" + }, + { + "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", + "when": "false" + } + ], + "view/title": [ + { + "command": "kilo-code.new.sidebarTitle.plusButtonClicked", + "group": "navigation@0", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.sidebarTitle.historyButtonClicked", + "group": "navigation@1", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.sidebarTitle.agentManagerOpen", + "group": "navigation@2", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.sidebarTitle.kiloClawOpen", + "group": "navigation@3", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", + "group": "navigation@4", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.sidebarTitle.profileButtonClicked", + "group": "navigation@5", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", + "group": "navigation@6", + "when": "view == kilo-code.SidebarProvider && !kilo-code.new.isCursor" } ], - "view/title": [], "scm/title": [ { "command": "kilo-code.new.generateCommitMessage", @@ -467,6 +566,26 @@ "command": "kilo-code.new.openInTab", "group": "navigation", "when": "true" + }, + { + "command": "kilo-code.new.plusButtonClicked", + "group": "navigation@0", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.historyButtonClicked", + "group": "navigation@1", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.profileButtonClicked", + "group": "navigation@2", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor" + }, + { + "command": "kilo-code.new.settingsButtonClicked", + "group": "navigation@3", + "when": "activeWebviewPanelId == kilo-code.new.TabPanel && !kilo-code.new.isCursor" } ], "editor/context": [ @@ -1072,6 +1191,11 @@ "default": false, "description": "Show tokens-per-second (prompt-processing / text-generation) badges on assistant messages and the task header" }, + "kilo-code.new.showAutoApprovalReason": { + "type": "boolean", + "default": true, + "description": "Show a line on tool calls explaining why they were auto-approved (matched rule, agent default, YOLO mode, etc.)" + }, "kilo-code.new.chat.shiftTabCyclesVariant": { "type": "boolean", "default": true, @@ -1107,12 +1231,12 @@ "prepare:cli-binary": "bun script/local-bin.ts", "prepare:sdk": "bun script/prepare-sdk.ts", "build:launch": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check:production", - "compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run build:check", - "watch": "bun run rebuild-sdk && bun run --parallel watch:esbuild watch:tsc", + "compile": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check", + "watch": "bun run prepare:sdk && bun run --parallel watch:esbuild watch:tsc", "watch:esbuild": "bun run prepare:cli-binary && node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "watch:cli": "bun script/watch-cli.ts", - "package": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run build:check:production", + "package": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check:production", "build:check": "bun run --parallel check-types check-types:webview lint bundle", "build:check:production": "bun run --parallel check-types check-types:webview lint bundle:production", "bundle": "bun esbuild.js", @@ -1144,6 +1268,8 @@ }, "devDependencies": { "@axe-core/playwright": "4.11.3", + "@babel/core": "^7.28.4", + "@babel/preset-typescript": "^7.27.1", "@playwright/test": "1.57.0", "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", @@ -1155,6 +1281,7 @@ "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.7.1", + "babel-preset-solid": "^1.9.9", "esbuild": "^0.27.2", "esbuild-plugin-solid": "^0.6.0", "eslint": "^9.39.2", diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 4b3c78e749..0ea79671b0 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -252,13 +252,15 @@ async function ensureBuiltBinary(): Promise { ) } - // Use the repository-pinned Bun version throughout. Newer canaries can fail compilation - // and must not cause packaged snapshots to fall back to the browser-mode source wrapper. const pkg = await Bun.file(join(repoDir, "package.json")).json() const bun = String(pkg.packageManager) - log("Installing dependencies in opencode package...") - await $`bunx ${bun} install --frozen-lockfile`.cwd(opencodeDir) - await $`bunx ${bun} run build --single --skip-install`.cwd(opencodeDir) + log("Building CLI binary...") + try { + await $`bunx ${bun} run build --single --skip-install`.cwd(opencodeDir) + } catch (err) { + log(`Pinned bunx build failed (${err}), running via active bun runtime...`) + await $`bun run script/build.ts --single --skip-install`.cwd(opencodeDir) + } const built = await findKiloBinaryInOpencodeDist() if (!built) { diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 15e8ec9574..4660904c00 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -17,7 +17,7 @@ import type { EditorContext, IndexingStatus } from "./services/cli-backend/types import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" import { notebookUri } from "./services/autocomplete/continuedev/core/autocomplete/notebook" -import { buildWebviewHtml, getWebviewFontSize } from "./utils" +import { buildWebviewHtml, getWebviewFontSize, isCursorHost } from "./utils" import { saveImage } from "./kilo-provider/save-image" import { handleEditorAction } from "./kilo-provider/editor-actions" import { exportTranscript } from "./kilo-provider/export-transcript" @@ -186,6 +186,10 @@ import { import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project/paths" import { validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings" import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings" +import { + buildAutoApprovalReasonSettingMessage, + watchAutoApprovalReasonConfig, +} from "./kilo-provider/auto-approval-reason-settings" let maxCost = 0 @@ -419,6 +423,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private indexingConfigDisposable: vscode.Disposable | null = null private chatConfigDisposable: vscode.Disposable | null = null private throughputConfigDisposable: vscode.Disposable | null = null + private autoApprovalReasonConfigDisposable: vscode.Disposable | null = null private telemetryStateDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null @@ -1002,6 +1007,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg)) this.throughputConfigDisposable?.dispose() this.throughputConfigDisposable = watchThroughputConfig((msg) => this.postMessage(msg)) + this.autoApprovalReasonConfigDisposable?.dispose() + this.autoApprovalReasonConfigDisposable = watchAutoApprovalReasonConfig((msg) => this.postMessage(msg)) this.telemetryStateDisposable?.dispose() this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { @@ -1832,6 +1839,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) + this.postMessage(buildAutoApprovalReasonSettingMessage()) this.postMessage({ type: "extensionDataReady" }) console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") @@ -4072,6 +4080,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) + this.postMessage(buildAutoApprovalReasonSettingMessage()) this.sendWorkStyle() await ModelState.reset(this.client, (msg) => this.postMessage(msg)) @@ -4909,7 +4918,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper title: "Kilo Code", port: this.connectionService.getServerInfo()?.port, extraStyles: `.container { height: 100vh; }`, - topBar: this.opts.hideTopBar !== true, + // Dedicated single-purpose panels (Settings, Profile, Sub-Agent Viewer) + // never show the bar. Sidebar and "Open in Tab" only need it in Cursor — + // VS Code's native toolbar (restored in package.json) works everywhere. + topBar: this.opts.hideTopBar !== true && isCursorHost(), topBarSurface: this.opts.topBarSurface === "tab" ? "tab_title" : "sidebar_title", }) } @@ -4998,6 +5010,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.indexingConfigDisposable?.dispose() this.chatConfigDisposable?.dispose() this.throughputConfigDisposable?.dispose() + this.autoApprovalReasonConfigDisposable?.dispose() this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() this.visibleTaskStreams.clear() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 0bc2cbe62e..2594aa9fbf 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -53,8 +53,10 @@ import { import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init" import { createLocalDiff } from "./local-diff" import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" +import { handleToolEvent } from "./tool-project" import { sandboxSessionMetadata } from "../shared/sandbox-session" -import { AgentManagerOrchestrationBridge } from "./orchestration-bridge" +import { createOrchestrationBridge } from "./orchestration-setup" +import type { AgentManagerOrchestrationBridge } from "./orchestration-bridge" import { pruneSubagents } from "./prune-subagents" import { startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" @@ -253,22 +255,20 @@ export class AgentManagerProvider implements Disposable { this.statsPoller = pollers.stats this.prBridge = pollers.pr this.projectPollers = pollers.projects - this.orchestration = new AgentManagerOrchestrationBridge(this.connectionService, { - root: () => this.getRoot(), - state: () => this.state, - ready: async () => { - this.stateReady ??= this.initializeState() - await this.stateReady - return this.state - }, - stats: () => this.statsPoller.snapshot(), - prs: () => this.prBridge.snapshot(), - push: () => this.pushState(), - managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id), - close: async (id) => { - await this.onCloseSession(id) - this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }) - }, + this.orchestration = createOrchestrationBridge({ + connectionService: this.connectionService, + contexts: this.contexts, + projectScope: this.projectScope, + getRoot: () => this.getRoot(), + getState: () => this.state, + getStateReady: () => this.stateReady, + initStateReady: () => (this.stateReady = this.initializeState()), + getStats: () => this.statsPoller.snapshot(), + getPrs: () => this.prBridge.snapshot(), + pushState: (ctx) => this.pushState(ctx), + hasPanelSession: (id) => this.panelSessions.has(id), + closeSession: (id) => this.onCloseSession(id), + postSessionClosed: (id) => this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }), log: (...args) => this.log(...args), }) this.unsubTool = this.connectionService.onEventFiltered( @@ -1081,14 +1081,16 @@ export class AgentManagerProvider implements Disposable { } private onToolEvent(event: unknown, directory?: string): void { - const properties = (event as { properties?: unknown }).properties - const req = parseToolRequest(properties) - if (!req) return - if (directory) { - req.directory = directory - req.projectId ??= this.contexts.byDirectory(directory)?.id - } - void this.startToolRequest(req) + handleToolEvent( + event, + directory, + { + byDirectory: (value) => this.contexts.byDirectory(value), + usable: (id) => this.contexts.usable(id), + }, + this.projectScope, + (req) => this.startToolRequest(req), + ) } private async startToolRequest(req: ToolRequest): Promise { @@ -1826,19 +1828,15 @@ export class AgentManagerProvider implements Disposable { public async createFromSidebar(baseBranch?: string, branchName?: string): Promise { this.openPanel() - const panel = this.panel - if (!panel) return - if (!(await this.waitForPanelReady(panel))) return + if (!this.panel || !(await this.waitForPanelReady(this.panel))) return await this.waitForStateReady("createFromSidebar") await this.onCreateWorktree(baseBranch, branchName) } public async openAdvancedWorktree(): Promise { this.openPanel() - const panel = this.panel - if (!panel) return - if (!(await this.waitForPanelActive(panel))) return - if (!(await this.waitForPanelReady(panel))) return + if (!this.panel || !(await this.waitForPanelActive(this.panel)) || !(await this.waitForPanelReady(this.panel))) + return await this.waitForStateReady("openAdvancedWorktree") queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" })) } diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts index e36948c449..e4e5d05360 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts @@ -41,14 +41,15 @@ interface Failure { } interface Options { - root(): string | undefined - ready(): Promise - state(): WorktreeStateManager | undefined - stats(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> - prs(): Map - push(): void - managed(sessionID: string): boolean - close(sessionID: string): Promise + root(directory?: string): string | undefined + ready(directory?: string): Promise + state(directory?: string): WorktreeStateManager | undefined + stats(directory?: string): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> + prs(directory?: string): Map + push(directory?: string): void + managed(sessionID: string, directory?: string): boolean + close(sessionID: string, directory?: string): Promise + directories?(): string[] log(...args: unknown[]): void } @@ -108,6 +109,7 @@ export class AgentManagerOrchestrationBridge { }) }) this.unsubscribeDirectories = connection.registerDirectoryProvider(() => { + if (this.options.directories) return this.options.directories() const root = this.options.root() const dirs = this.options @@ -181,8 +183,8 @@ export class AgentManagerOrchestrationBridge { } private async admit(request: Request, directory: string): Promise { - const state = await this.options.ready() - const root = this.options.root() + const state = await this.options.ready(directory) + const root = this.options.root(directory) if (this.disposed || this.settled.has(request.id)) return if (!state || !root) { const accepted = await this.reject(request.id, directory, { @@ -232,7 +234,7 @@ export class AgentManagerOrchestrationBridge { private async run(request: Request, origin: Origin, active: Active): Promise { try { - const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, active)) + const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin, active)) if (!outcome || this.disposed || active.cancelled) return this.rememberOutcome(request.id, outcome) const accepted = @@ -248,10 +250,10 @@ export class AgentManagerOrchestrationBridge { } } - private async execute(request: Request, active: Active): Promise { + private async execute(request: Request, origin: Origin, active: Active): Promise { try { - const state = await this.options.ready() - const root = this.options.root() + const state = await this.options.ready(origin.directory) + const root = this.options.root(origin.directory) if (!state || !root) throw new OrchestrationError("workspace_unavailable", "Agent Manager requires an open workspace") if (this.disposed || active.cancelled) return @@ -260,7 +262,7 @@ export class AgentManagerOrchestrationBridge { // Git stats are refreshed by the poller independently. A forced refresh // here can spawn one diff/ahead-behind pair per worktree and exceed the // host request timeout before the overview can return its IDs. - const stats = await this.options.stats() + const stats = await this.options.stats(origin.directory) if (this.disposed || active.cancelled) return const result = await overview({ client, @@ -269,7 +271,7 @@ export class AgentManagerOrchestrationBridge { titles: this.titles, filter: request.filter, stats, - prs: this.options.prs(), + prs: this.options.prs(origin.directory), }) return { result: { operation: "overview", overview: result } } } @@ -288,7 +290,7 @@ export class AgentManagerOrchestrationBridge { } if (request.operation === "move") { move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID }) - this.options.push() + this.options.push(origin.directory) if (this.disposed || active.cancelled) return return { result: { @@ -299,10 +301,10 @@ export class AgentManagerOrchestrationBridge { }, } } - if (!this.options.managed(request.targetSessionID)) { + if (!this.options.managed(request.targetSessionID, origin.directory)) { throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") } - await this.options.close(request.targetSessionID) + await this.options.close(request.targetSessionID, origin.directory) if (this.disposed || active.cancelled) return return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } } } catch (error) { diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts index b8b0828271..f1b7849c32 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts @@ -319,6 +319,7 @@ export async function prompt(input: { text: string messageID: string signal?: AbortSignal + idleTimeoutMs?: number }): Promise { if (input.signal?.aborted) return const managed = input.state.getSession(input.sessionID) @@ -342,15 +343,7 @@ export async function prompt(input: { if (!(await sameManagedDirectory(response.data.directory, dir))) { throw new OrchestrationError("cross_workspace", "The managed session belongs to a different workspace directory") } - const status = await input.client.session.status({ directory: dir }) - if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read") - const activity = status.data?.[input.sessionID]?.type ?? "idle" - if (activity !== "idle") { - throw new OrchestrationError( - "unavailable_session", - `The managed session is ${activity}; only idle sessions can be prompted`, - ) - } + await waitForIdle(input.client, dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000) if (input.signal?.aborted) return await input.client.session.promptAsync( { @@ -364,6 +357,29 @@ export async function prompt(input: { ) } +async function waitForIdle( + client: KiloClient, + directory: string, + sessionID: string, + signal: AbortSignal | undefined, + timeout: number, + start = Date.now(), +): Promise { + if (signal?.aborted) return + const status = await client.session.status({ directory }) + if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read") + const activity = status.data?.[sessionID]?.type ?? "idle" + if (activity === "idle") return + if (Date.now() - start >= timeout) { + throw new OrchestrationError( + "unavailable_session", + `The managed session is still ${activity}; only idle sessions can be prompted`, + ) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + return waitForIdle(client, directory, sessionID, signal, timeout, start) +} + export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void { const session = input.state.getSession(input.sessionID) if (!session) diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts b/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts new file mode 100644 index 0000000000..1f24b314ea --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts @@ -0,0 +1,78 @@ +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import { AgentManagerOrchestrationBridge } from "./orchestration-bridge" +import type { ProjectContexts } from "./project/contexts" +import type { ProjectContext } from "./project/context" +import type { ProjectScope } from "./project/scope" +import type { WorktreeStateManager } from "./WorktreeStateManager" +import type { WorktreeStats, LocalStats } from "./GitStatsPoller" +import type { PRStatus } from "./types" +import { initContextState } from "./project/init" + +export interface OrchestrationBridgeDeps { + connectionService: KiloConnectionService + contexts: ProjectContexts + projectScope: ProjectScope + getRoot: () => string | undefined + getState: () => WorktreeStateManager | undefined + getStateReady: () => Promise | undefined + initStateReady: () => Promise + getStats: () => Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> + getPrs: () => Map + pushState: (ctx?: ProjectContext) => void + hasPanelSession: (id: string) => boolean + closeSession: (id: string) => Promise + postSessionClosed: (id: string) => void + log: (...args: unknown[]) => void +} + +export function createOrchestrationBridge(deps: OrchestrationBridgeDeps): AgentManagerOrchestrationBridge { + return new AgentManagerOrchestrationBridge(deps.connectionService, { + root: (dir) => (dir ? deps.contexts.byDirectory(dir)?.root : undefined) ?? deps.getRoot(), + state: (dir) => (dir ? deps.contexts.byDirectory(dir)?.peekState() : undefined) ?? deps.getState(), + ready: async (dir) => { + const ctx = dir ? deps.contexts.byDirectory(dir) : undefined + if (ctx && ctx.id !== deps.contexts.active()?.id) { + await initContextState(ctx, (...args) => deps.log(...args)) + return ctx.stateManager() + } + const ready = deps.getStateReady() ?? deps.initStateReady() + await ready + return deps.getState() + }, + stats: () => deps.getStats(), + prs: () => deps.getPrs(), + push: (dir) => { + const ctx = dir ? deps.contexts.byDirectory(dir) : undefined + deps.pushState(ctx) + }, + managed: (id, dir) => { + const ctx = dir ? deps.contexts.byDirectory(dir) : undefined + if (ctx) return ctx.hasLiveSession(id) || !!ctx.peekState()?.getSession(id) + return deps.hasPanelSession(id) || !!deps.getState()?.getSession(id) + }, + close: async (id, dir) => { + const ctx = dir ? deps.contexts.byDirectory(dir) : undefined + if (ctx) { + await deps.projectScope.run(ctx, () => deps.closeSession(id)) + } else { + await deps.closeSession(id) + } + deps.postSessionClosed(id) + }, + directories: () => { + const all: string[] = [] + for (const ctx of deps.contexts.values()) { + all.push(ctx.root) + for (const wt of ctx.peekState()?.getWorktrees() ?? []) { + if (wt.path) all.push(wt.path) + } + } + if (all.length === 0) { + const root = deps.getRoot() + if (root) all.push(root) + } + return all + }, + log: (...args) => deps.log(...args), + }) +} diff --git a/packages/kilo-vscode/src/agent-manager/project/contexts.ts b/packages/kilo-vscode/src/agent-manager/project/contexts.ts index 95a16d7050..310ee24047 100644 --- a/packages/kilo-vscode/src/agent-manager/project/contexts.ts +++ b/packages/kilo-vscode/src/agent-manager/project/contexts.ts @@ -100,6 +100,10 @@ export class ProjectContexts { return this.contexts.get(id) } + values(): IterableIterator { + return this.contexts.values() + } + /** The context that owns a directory: its root or one of its worktree paths. */ byDirectory(dir: string): ProjectContext | undefined { for (const ctx of this.contexts.values()) { diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index 40b98a58e1..26677dc2d3 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -51,6 +51,7 @@ interface Entry { export class TerminalManager { private readonly entries = new Map() private readonly restarts = new Map>() + private readonly pending = new Map() constructor(private readonly deps: TerminalManagerDeps) {} @@ -67,7 +68,14 @@ export class TerminalManager { worktreeId: string | null cwd: string title: string + cols?: number + rows?: number }): Promise<{ terminalId: string; worktreeId: string | null; title: string; wsUrl: string }> { + const initial = + this.pending.get(params.terminalId) ?? + (params.cols !== undefined && params.rows !== undefined ? { cols: params.cols, rows: params.rows } : undefined) + this.pending.delete(params.terminalId) + const client = this.deps.getClient() const { data, error } = await client.pty.create({ directory: params.cwd, @@ -76,6 +84,7 @@ export class TerminalManager { // xterm's DOM renderer cannot draw the Unicode sextant glyphs used by // Kilo's modern wordmark, so use the compatible logo in embedded tabs. env, + size: initial, }) if (error || !data) { const err = error instanceof Error ? error.message : String(error ?? "unknown error") @@ -89,15 +98,39 @@ export class TerminalManager { title: data.title ?? params.title, } this.entries.set(params.terminalId, entry) + // If a resize arrived while pty.create was in flight that differed from `initial`, apply it now. + const latest = this.pending.get(params.terminalId) + if (latest && (latest.cols !== initial?.cols || latest.rows !== initial?.rows)) { + this.pending.delete(params.terminalId) + const { error: resizeErr } = await client.pty.update({ + directory: entry.cwd, + ptyID: entry.ptyID, + size: latest, + }) + if (resizeErr) { + const err = resizeErr instanceof Error ? resizeErr.message : String(resizeErr) + this.deps.log(`Initial terminal resize failed (${params.terminalId}): ${err}`) + } + } const wsUrl = this.deps.buildWsUrl(entry.ptyID, entry.cwd) this.deps.log(`Terminal created: ${params.terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`) return { terminalId: params.terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl } } - /** Forward a resize event to the backend PTY. Missing terminals are a no-op. */ + /** + * Forward a resize event to the backend PTY. + * + * If the terminal creation is still in flight, dimensions are queued into + * `pending` and applied during PTY initialization before the WebSocket + * URL is returned. + */ async resize(terminalId: string, cols: number, rows: number): Promise { const entry = this.entries.get(terminalId) - if (!entry) return + if (!entry) { + this.pending.set(terminalId, { cols, rows }) + return + } + this.pending.delete(terminalId) const client = this.deps.getClient() const { error } = await client.pty.update({ directory: entry.cwd, @@ -126,6 +159,7 @@ export class TerminalManager { * failed delete would be silently logged as a successful close and * the server-side PTY would linger until `kilo serve` exits. */ async close(terminalId: string): Promise { + this.pending.delete(terminalId) const entry = this.entries.get(terminalId) if (!entry) return this.entries.delete(terminalId) @@ -187,6 +221,7 @@ export class TerminalManager { * is sampled mid-shutdown. */ async dispose(): Promise { + this.pending.clear() const snapshot = [...this.entries.values()] if (snapshot.length === 0) { this.entries.clear() diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 95fe5c8632..7a4a74520f 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -88,7 +88,7 @@ export class TerminalRouter { handle(m: AgentManagerInMessage): boolean { if (!isTerminalMessage(m)) return false if (m.type === "agentManager.terminal.create") { - void this.handleCreate(m.createId, m.placement, m.worktreeId) + void this.handleCreate(m.createId, m.placement, m.worktreeId, m.cols, m.rows) return true } if (m.type === "agentManager.terminal.close") { @@ -132,7 +132,13 @@ export class TerminalRouter { return manager.dispose() } - private async handleCreate(createId: string, placement: TerminalPlacement, worktreeId: string | null): Promise { + private async handleCreate( + createId: string, + placement: TerminalPlacement, + worktreeId: string | null, + cols?: number, + rows?: number, + ): Promise { const generation = this.generation const manager = this.manager const cwd = this.resolveCwd(worktreeId) @@ -155,7 +161,7 @@ export class TerminalRouter { // Join the shared backend connection instead of racing its synchronous // client accessor when this is the first Kilo action in the window. await this.deps.getClientAsync() - const created = await manager.create({ terminalId: createId, worktreeId, cwd, title }) + const created = await manager.create({ terminalId: createId, worktreeId, cwd, title, cols, rows }) if (generation !== this.generation) { await manager.close(created.terminalId) return diff --git a/packages/kilo-vscode/src/agent-manager/tool-project.ts b/packages/kilo-vscode/src/agent-manager/tool-project.ts new file mode 100644 index 0000000000..f6959107bd --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/tool-project.ts @@ -0,0 +1,32 @@ +import type { ToolRequest } from "./tool-start" +import { parseToolRequest } from "./tool-start" + +export function routeToolRequest( + input: T, + directory: string | undefined, + deps: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined }, +): { request: T; owner?: C } { + const request = directory ? { ...input, directory } : input + const owner = + (directory && deps.byDirectory(directory)) ?? (request.projectId ? deps.usable(request.projectId) : undefined) + if (!owner) return { request } + return { request: { ...request, projectId: owner.id }, owner } +} + +export function handleToolEvent( + event: unknown, + directory: string | undefined, + contexts: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined }, + scope: { run: (owner: C, fn: () => Promise) => Promise }, + start: (req: ToolRequest) => Promise, +): void { + const properties = (event as { properties?: unknown }).properties + const req = parseToolRequest(properties) + if (!req) return + const routed = routeToolRequest(req, directory, contexts) + if (routed.owner) { + void scope.run(routed.owner, () => start(routed.request)) + return + } + void start(routed.request) +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 7064f16d22..7e6792f10b 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -946,6 +946,8 @@ interface TerminalCreateIn { placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null + cols?: number + rows?: number } interface TerminalCloseIn { diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 43843650dd..e5b4e92c8f 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -17,7 +17,7 @@ import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-bac import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" import { AttentionService } from "./services/attention" import { BrowserAutomationService } from "./services/browser-automation" -import { TelemetryProxy } from "./services/telemetry" +import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions" import { registerToggleAutoApprove } from "./commands/toggle-auto-approve" @@ -26,6 +26,7 @@ import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" import { createNotebookBridge } from "./services/notebook" import { createGitExecutable } from "./util/git-executable" +import { isCursorHost } from "./utils" let agentManager: AgentManagerProvider | undefined let shuttingDown = false @@ -48,6 +49,10 @@ export function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") shuttingDown = false + // Drives the "!kilo-code.new.isCursor" guards on the native view/title and + // editor/title menu contributions — see isCursorHost() for why. + void vscode.commands.executeCommand("setContext", "kilo-code.new.isCursor", isCursorHost()) + const telemetry = TelemetryProxy.getInstance() // Create shared connection service (one server for all webviews) @@ -339,8 +344,39 @@ export function activate(context: vscode.ExtensionContext) { }), ) + // Sidebar menus use wrapper commands so this event measures real title button presses, + // not programmatic opens, shortcuts, or editor title commands. + const track = (button: string, command: string) => { + TelemetryProxy.capture(TelemetryEventName.TITLE_BUTTON_CLICKED, { + button, + surface: "sidebar_title", + }) + void vscode.commands.executeCommand(command) + } + // Register toolbar button command handlers context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.plusButtonClicked", () => { + track("new_task", "kilo-code.new.plusButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.historyButtonClicked", () => { + track("history", "kilo-code.new.historyButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.agentManagerOpen", () => { + track("agent_manager", "kilo-code.new.agentManagerOpen") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.kiloClawOpen", () => { + track("kiloclaw", "kilo-code.new.kiloClawOpen") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.marketplaceButtonClicked", () => { + track("marketplace", "kilo-code.new.marketplaceButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.profileButtonClicked", () => { + track("profile", "kilo-code.new.profileButtonClicked") + }), + vscode.commands.registerCommand("kilo-code.new.sidebarTitle.settingsButtonClicked", () => { + track("settings", "kilo-code.new.settingsButtonClicked") + }), vscode.commands.registerCommand("kilo-code.new.plusButtonClicked", () => { const tab = activeTabProvider() if (tab) tab.postMessage({ type: "action", action: "plusButtonClicked" }) diff --git a/packages/kilo-vscode/src/kilo-provider/auto-approval-reason-settings.ts b/packages/kilo-vscode/src/kilo-provider/auto-approval-reason-settings.ts new file mode 100644 index 0000000000..3f0ba8f2c7 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/auto-approval-reason-settings.ts @@ -0,0 +1,19 @@ +import * as vscode from "vscode" + +type Post = (msg: unknown) => void + +export function buildAutoApprovalReasonSettingMessage() { + const config = vscode.workspace.getConfiguration("kilo-code.new") + return { + type: "autoApprovalReasonSettingLoaded" as const, + visible: config.get("showAutoApprovalReason", true), + } +} + +export function watchAutoApprovalReasonConfig(post: Post): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("kilo-code.new.showAutoApprovalReason")) { + post(buildAutoApprovalReasonSettingMessage()) + } + }) +} diff --git a/packages/kilo-vscode/src/kilo-provider/commands.ts b/packages/kilo-vscode/src/kilo-provider/commands.ts index d641fff1f3..822ef45ba2 100644 --- a/packages/kilo-vscode/src/kilo-provider/commands.ts +++ b/packages/kilo-vscode/src/kilo-provider/commands.ts @@ -13,12 +13,18 @@ export async function loadCommands(client: KiloClient, dir: string): Promise client.command.list({ directory: dir }, { throwOnError: true })).then(({ data }) => ({ type: "commandsLoaded", - commands: data.map((cmd) => ({ - name: cmd.name, - description: cmd.description, - source: cmd.source, - hints: cmd.hints, - })), + commands: data.map((cmd) => { + const item = cmd as typeof cmd & { variant?: string } + return { + name: item.name, + description: item.description, + agent: item.agent, + model: item.model, + variant: item.variant, + source: item.source, + hints: item.hints, + } + }), })) promises.set(dir, promise) diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index b99a626219..49240367cb 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -6,6 +6,7 @@ import type { SuggestionContext } from "./handlers/suggestion" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildChatSettingsMessage } from "./chat-settings" import { buildThroughputSettingMessage } from "./throughput-settings" +import { buildAutoApprovalReasonSettingMessage } from "./auto-approval-reason-settings" import { handleModelUsageMessage, type ModelUsageMessage } from "./model-usage" type Ctx = { @@ -71,6 +72,10 @@ export async function routeEarlyMessage( ctx.post(buildThroughputSettingMessage()) return true } + if (message.type === "requestAutoApprovalReasonSetting") { + ctx.post(buildAutoApprovalReasonSettingMessage()) + return true + } if (message.type === "requestSpeechToTextModels") { await ctx.speechToTextModels() return true diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts index e8b6125107..4b35415842 100644 --- a/packages/kilo-vscode/src/speech-to-text/capture.ts +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -46,7 +46,10 @@ function run(args) { const error = Ref() const url = $.NSURL.fileURLWithPath(args[0]) const recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, error) - if (!recorder || !recorder.prepareToRecord || !recorder.record) throw new Error("Could not start recording") + if (!recorder || !recorder.prepareToRecord || !recorder.record) { + const description = error[0] && error[0].localizedDescription + throw new Error(description ? description.js : "Could not start recording") + } console.log("ready") $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile recorder.stop diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 23479043e8..6e3b6b49c0 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -18,6 +18,27 @@ export function getWebviewFontSize(): number { return clamp(raw) } +/** + * True when running inside Cursor rather than real VS Code (or another + * fork). Cursor's Secondary Side Bar support is known to be unreliable for + * extension-contributed `view/title` toolbars (see + * https://github.com/anthropics/claude-code/issues/31375 for the same class + * of bug in a different extension), so Cursor falls back to an in-webview + * navigation bar instead of the native toolbar that VS Code renders fine + * everywhere. + * + * This is a per-host choice, not a per-dock-location one: `WebviewView` (and + * the rest of the public API, checked against @types/vscode) exposes no way + * to ask "is my view currently in the primary or secondary side bar", so the + * webview fallback bar is Cursor's only option in both locations. Accepted + * trade-off: Cursor's primary side bar loses the single-line native look it + * had before this existed, in exchange for the Secondary Side Bar actually + * working, with zero guessing about dock position anywhere. + */ +export function isCursorHost(): boolean { + return vscode.env.appName.toLowerCase().includes("cursor") +} + function fontStyle(): string { const base = getWebviewFontSize() const vars = SIZES.map((size) => `--kilo-font-size-${size}: ${(base * size) / 13}px;`).join("\n ") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts index dded9b2ed5..c5cd830f04 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts @@ -33,7 +33,9 @@ describe("AgentManagerOrchestrationBridge", () => { fs.rmSync(root, { recursive: true, force: true }) }) - function harness() { + function harness( + overrides?: Partial[1]>, + ) { const replies: unknown[] = [] const rejections: unknown[] = [] const lists = new Map() @@ -49,8 +51,8 @@ describe("AgentManagerOrchestrationBridge", () => { const push = mock(() => undefined) const client = { session: { - get: mock(async () => ({ - data: { id: "ses_target", directory: dir, title: "Target" } as Session, + get: mock(async ({ sessionID, directory }: { sessionID?: string; directory?: string }) => ({ + data: { id: sessionID ?? "ses_target", directory: directory ?? dir, title: "Target" } as Session, })), status: mock(async () => ({ data: {} })), promptAsync, @@ -100,17 +102,17 @@ describe("AgentManagerOrchestrationBridge", () => { getClient: () => client, } const bridge = new AgentManagerOrchestrationBridge(connection as never, { - root: () => root, - ready: async () => state, - state: () => state, - stats: async () => { + root: (dir) => (overrides?.root ? overrides.root(dir) : root), + ready: async (dir) => (overrides?.ready ? overrides.ready(dir) : state), + state: (dir) => (overrides?.state ? overrides.state(dir) : state), + stats: async (dir) => { statsCalls.push(1) - return { worktrees: [] } + return overrides?.stats ? overrides.stats(dir) : { worktrees: [] } }, - prs: () => new Map(), - push, - managed: (id) => managed.has(id), - close, + prs: (dir) => (overrides?.prs ? overrides.prs(dir) : new Map()), + push: (dir) => (overrides?.push ? overrides.push(dir) : push()), + managed: (id, dir) => (overrides?.managed ? overrides.managed(id, dir) : managed.has(id)), + close: async (id, dir) => (overrides?.close ? overrides.close(id, dir) : close(id, dir)), log: () => undefined, }) const request = (value: AgentManagerRequest, directory = root) => @@ -195,7 +197,7 @@ describe("AgentManagerOrchestrationBridge", () => { await waitFor(() => test.replies.length === 2) expect(test.close).toHaveBeenCalledTimes(1) - expect(test.close).toHaveBeenCalledWith("ses_target") + expect(test.close).toHaveBeenCalledWith("ses_target", root) expect(test.replies).toEqual([ { requestID: "amr_stop", @@ -294,7 +296,7 @@ describe("AgentManagerOrchestrationBridge", () => { await waitFor(() => test.replies.length === 1) expect(state.getSession("ses_live")).toBeUndefined() - expect(test.close).toHaveBeenCalledWith("ses_live") + expect(test.close).toHaveBeenCalledWith("ses_live", root) expect(test.replies[0]).toEqual({ requestID: "amr_stop_live", directory: root, @@ -389,4 +391,39 @@ describe("AgentManagerOrchestrationBridge", () => { expect(test.promptAsync).toHaveBeenCalledTimes(1) test.bridge.dispose() }) + + it("handles requests for secondary project directories in multi-project mode", async () => { + const secondaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "am-orchestration-secondary-")) + fs.mkdirSync(path.join(secondaryRoot, ".kilo"), { recursive: true }) + const secondaryState = new WorktreeStateManager(secondaryRoot, () => undefined) + secondaryState.addSession("ses_secondary", null) + + const test = harness({ + root: (d) => (d === secondaryRoot ? secondaryRoot : root), + ready: async (d) => (d === secondaryRoot ? secondaryState : state), + state: (d) => (d === secondaryRoot ? secondaryState : state), + }) + + test.request( + { + id: "amr_secondary", + sessionID: "ses_caller", + operation: "prompt", + targetSessionID: "ses_secondary", + prompt: "Hello from secondary", + }, + secondaryRoot, + ) + await waitFor(() => test.replies.length === 1) + + expect(test.promptAsync).toHaveBeenCalledTimes(1) + expect(test.replies[0]).toEqual({ + requestID: "amr_secondary", + directory: secondaryRoot, + result: { operation: "prompt", sessionID: "ses_secondary", delivered: true }, + }) + test.bridge.dispose() + await secondaryState.flush() + fs.rmSync(secondaryRoot, { recursive: true, force: true }) + }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts index e7a6e0fb17..74197ac049 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts @@ -202,6 +202,25 @@ describe("Agent Manager orchestration domain", () => { ) }) + it("waits for a busy managed session to become idle before prompting", async () => { + const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" }) + state.addSession("ses_wait", managed.id) + let calls = 0 + const promptAsync = mock(async () => ({ data: undefined })) + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_wait", directory: worktree, title: "Wait" } as Session })), + status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })), + promptAsync, + }, + } as unknown as KiloClient + + await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" }) + + expect(client.session.status).toHaveBeenCalledTimes(2) + expect(promptAsync).toHaveBeenCalledTimes(1) + }) + it("rejects unknown, stale, cross-workspace, and busy targets", async () => { const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" }) state.addSession("ses_target", managed.id) @@ -231,7 +250,15 @@ describe("Agent Manager orchestration domain", () => { data: { ses_target: { type: "busy" } }, })) await expect( - prompt({ client, root, state, sessionID: "ses_target", text: "Continue", messageID: "amr_busy" }), + prompt({ + client, + root, + state, + sessionID: "ses_target", + text: "Continue", + messageID: "amr_busy", + idleTimeoutMs: 0, + }), ).rejects.toMatchObject({ code: "unavailable_session", } satisfies Partial) 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 index f195876afa..e39170102d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -58,6 +58,29 @@ test("does not refit hidden terminal buffers during resize", () => { expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()")) }) +test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => { + expect(terminal).toContain("convertEol: false") + expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"') + expect(terminal.indexOf("term.loadAddon(new UnicodeGraphemesAddon())")).toBeLessThan( + terminal.indexOf("open(props.wsUrl)"), + ) +}) + +test("fits and forces the initial PTY dimensions before socket attach", () => { + expect(terminal).toContain("const syncSize = (force = false)") + expect(terminal).toContain("if (props.active) syncSize(true)") + expect(terminal.indexOf("fitNow()\n open(props.wsUrl)")).toBeGreaterThan(-1) +}) + +test("re-sends dimensions when an optimistic terminal receives its PTY", () => { + const created = terminal.match( + /if \(message\.terminalId === props\.terminalId && !ws\) \{([\s\S]*?)\n \}/, + )?.[1] + expect(created).toBeDefined() + expect(created).toContain("fitNow()") + expect(created!.indexOf("fitNow()")).toBeLessThan(created!.indexOf("open(message.wsUrl)")) +}) + test("clamps the restored inspector width to the shared layout bounds", () => { expect(clampPanelWidth(undefined, 1200)).toBe(600) expect(clampPanelWidth(500, 1200)).toBe(500) 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 709a704b8f..6950d692e5 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 @@ -343,4 +343,62 @@ describe("Agent Manager terminal routing", () => { }) await router.dispose() }) + + it("applies initial create dimensions and queues resize messages before creation settles", async () => { + const creates: Array> = [] + const updates: Array<{ ptyID: string; size?: { cols: number; rows: number } }> = [] + let createResolver: ((value: { data: { id: string; title: string } }) => void) | undefined + const client = { + pty: { + create: (params: Record) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + creates.push(params) + createResolver = resolve + }), + remove: async () => ({ data: true }), + update: async (params: { ptyID: string; size?: { cols: number; rows: number } }) => { + updates.push(params) + return { data: true } + }, + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + getProjectId: () => "prj-1", + log: () => undefined, + post: () => undefined, + getTerminalFont: () => font, + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "queued", + placement: "side", + worktreeId: null, + cols: 60, + rows: 20, + }) + await wait() + expect(creates).toHaveLength(1) + expect(creates[0]?.size).toEqual({ cols: 60, rows: 20 }) + + // Send a resize before pty.create settles (optimistic side terminal layout) + router.handle({ + type: "agentManager.terminal.resize", + terminalId: "queued", + cols: 55, + rows: 18, + }) + await wait() + expect(updates).toHaveLength(0) + + createResolver?.({ data: { id: "pty-queued", title: "Terminal 1" } }) + await wait() + expect(updates).toEqual([{ directory: "/workspace", ptyID: "pty-queued", size: { cols: 55, rows: 18 } }]) + await router.dispose() + }) }) 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 4d6a9848d1..4b1c633f58 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 @@ -83,6 +83,23 @@ describe("Agent Manager side terminal controller", () => { expect(hidden.calls.hide).toBe(0) }) + it("toggles panel visibility from toolbar button without requiring focus", () => { + const visibleUnfocused = scene({ destination: "agentManager", visible: true }) + visibleUnfocused.ctl.openPreferred("tab_toolbar") + expect(visibleUnfocused.calls.hide).toBe(1) + expect(visibleUnfocused.calls.requestSide).toBe(0) + + const visibleFocused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" }) + visibleFocused.ctl.openPreferred("tab_toolbar") + expect(visibleFocused.calls.hide).toBe(1) + expect(visibleFocused.calls.requestSide).toBe(0) + + const hidden = scene({ destination: "agentManager", visible: false }) + hidden.ctl.openPreferred("tab_toolbar") + expect(hidden.calls.requestSide).toBe(1) + expect(hidden.calls.hide).toBe(0) + }) + it("ensures an open terminal panel has a terminal after switching contexts", async () => { const visible = scene({ visible: true }) visible.ctl.syncContext("wt-2", "wt-1") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-project.test.ts new file mode 100644 index 0000000000..7c720d75d2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-project.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test" +import { routeToolRequest } from "../../src/agent-manager/tool-project" + +describe("Agent Manager tool project routing", () => { + it("routes by the event directory before any explicit project id", () => { + const secondary = { id: "prj-secondary" } + const request = routeToolRequest({ requestID: "am-1", projectId: "prj-active", mode: "worktree" }, "/secondary", { + byDirectory: (dir) => (dir === "/secondary" ? secondary : undefined), + usable: () => ({ id: "prj-active" }), + }) + + expect(request.owner).toBe(secondary) + expect(request.request).toEqual({ + requestID: "am-1", + projectId: "prj-secondary", + mode: "worktree", + directory: "/secondary", + }) + }) + + it("uses an explicit usable project when no event directory is available", () => { + const project = { id: "prj-secondary" } + const request = routeToolRequest({ requestID: "am-2", projectId: "prj-secondary", mode: "local" }, undefined, { + byDirectory: () => undefined, + usable: (id) => (id === project.id ? project : undefined), + }) + + expect(request.owner).toBe(project) + expect(request.request.projectId).toBe("prj-secondary") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/esbuild-dependencies.test.ts b/packages/kilo-vscode/tests/unit/esbuild-dependencies.test.ts new file mode 100644 index 0000000000..d200eee6a8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/esbuild-dependencies.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { builtinModules } from "node:module" + +const ROOT = path.resolve(import.meta.dir, "../..") +const PKG_FILE = path.join(ROOT, "package.json") +const ESBUILD_FILE = path.join(ROOT, "esbuild.js") + +const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]) + +function extractPackageName(specifier: string): string | null { + if (specifier.startsWith(".") || specifier.startsWith("/")) return null + if (BUILTINS.has(specifier)) return null + if (specifier.startsWith("node:")) return null + + if (specifier.startsWith("@")) { + const parts = specifier.split("/") + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier + } + return specifier.split("/")[0] +} + +function findImportsAndRequires(content: string): string[] { + const specifiers = new Set() + const requireRegex = /require\(["']([^"']+)["']\)/g + const importRegex = /(?:import|from)\s+["']([^"']+)["']/g + + for (const match of content.matchAll(requireRegex)) { + specifiers.add(match[1]) + } + for (const match of content.matchAll(importRegex)) { + specifiers.add(match[1]) + } + + return Array.from(specifiers) +} + +describe("Build Script Dependency Declarations", () => { + it("esbuild.js must declare all imported/required packages in package.json", () => { + const pkg = JSON.parse(fs.readFileSync(PKG_FILE, "utf8")) + const declared = new Set([ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.devDependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + "vscode", + ]) + + const esbuildContent = fs.readFileSync(ESBUILD_FILE, "utf8") + const specifiers = findImportsAndRequires(esbuildContent) + + const undeclared: string[] = [] + for (const spec of specifiers) { + const pkgName = extractPackageName(spec) + if (pkgName && !declared.has(pkgName)) { + undeclared.push(`${spec} (package: ${pkgName})`) + } + } + + expect(undeclared).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 8cce494525..f94736a960 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -78,6 +78,15 @@ describe("sendCommand dismisses pending tool requests", () => { it("rejects questions before sending", () => { expect(body).toContain("dismissQuestion") }) + + it("applies model, agent, and variant overrides when provided by a command", () => { + expect(body).toContain("if (overrides?.agent)") + expect(body).toContain("selectAgent(overrides.agent, scope)") + expect(body).toContain("if (overrides?.model)") + expect(body).toContain("selectModel(parsed.providerID, parsed.modelID, scope)") + expect(body).toContain("if (overrides?.variant)") + expect(body).toContain("selectVariant(overrides.variant, scope)") + }) }) describe("static command completion contract", () => { diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts index b76c8e8c7a..a0e7120b04 100644 --- a/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts +++ b/packages/kilo-vscode/tests/unit/speech-to-text-capture.test.ts @@ -19,6 +19,7 @@ describe("macCaptureArgs", () => { expect(args[3]).toContain("numberWithDouble(16000), $.AVSampleRateKey") expect(args[3]).toContain("numberWithInt(1), $.AVNumberOfChannelsKey") expect(args[3]).toContain("numberWithInt(24000), $.AVEncoderBitRateKey") + expect(args[3]).toContain("error[0] && error[0].localizedDescription") expect(args[3]).toContain('console.log("ready")') }) }) diff --git a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts index 710ca744be..2c8b5f39d2 100644 --- a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -188,4 +188,76 @@ describe("useSlashCommand sandbox action", () => { expect(ctx.slash.results()[0]?.description).toBe("Toggle sandbox") ctx.dispose() }) + + it("opens review options from the top-level command", () => { + const ctx = setup(() => {}) + const state = { text: "/review" } + const textarea = { + value: state.text, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + ctx.slash.onInput("/rev", 4) + + expect(ctx.slash.results()).toContainEqual( + expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }), + ) + ctx.slash.select(ctx.slash.results().find((c) => c.name === "review")!, textarea, (text) => (state.text = text)) + expect(state.text).toBe("/review ") + expect(ctx.slash.results().map((command) => command.name)).toEqual([ + "review uncommitted", + "review staged", + "review unpushed", + "review branch", + "review quick", + ]) + ctx.dispose() + }) + + it("completes nested review actions and closes for free text", () => { + const ctx = setup(() => {}) + const state = { text: "/review unp" } + const textarea = { + value: state.text, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + + ctx.slash.onInput(state.text, state.text.length) + expect(ctx.slash.results().map((command) => command.name)).toEqual(["review unpushed"]) + ctx.slash.select(ctx.slash.results()[0]!, textarea, (text) => (state.text = text)) + expect(state.text).toBe("/review unpushed ") + + ctx.slash.onInput("/review focus on auth", 20) + expect(ctx.slash.show()).toBe(false) + ctx.dispose() + }) + + it("preserves model, agent, and variant metadata on loaded server commands", () => { + const ctx = setup(() => {}) + + ctx.fire({ + type: "commandsLoaded", + commands: [ + { + name: "ship", + description: "Ship PR", + agent: "code", + model: "openai/gpt-5.6-luna-fast", + variant: "xhigh", + hints: ["deploy"], + }, + ], + }) + + ctx.slash.onInput("/ship", 5) + const matches = ctx.slash.results() + expect(matches).toHaveLength(1) + expect(matches[0]?.name).toBe("ship") + expect(matches[0]?.agent).toBe("code") + expect(matches[0]?.model).toBe("openai/gpt-5.6-luna-fast") + expect(matches[0]?.variant).toBe("xhigh") + ctx.dispose() + }) }) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 9569a571fa..67174fb21a 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -24,17 +24,24 @@ afterEach(async () => { ) }) +function gitExec(args: string[]) { + const res = Bun.spawnSync(args, { stdout: "ignore", stderr: "pipe" }) + if (res.exitCode !== 0) { + const err = Buffer.from(res.stderr).toString("utf8") + throw new Error(`git command failed (${args.join(" ")}): ${err}`) + } +} + /** Create a temp git repo with an initial commit (required for worktrees). */ async function createTempRepo(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-")) tempDirs.push(dir) - const git = simpleGit(dir) - await git.init() - await git.addConfig("user.email", "test@test.com") - await git.addConfig("user.name", "Test") + gitExec(["git", "init", "-b", "main", dir]) + gitExec(["git", "-C", dir, "config", "user.email", "test@test.com"]) + gitExec(["git", "-C", dir, "config", "user.name", "Test"]) await fs.writeFile(path.join(dir, "README.md"), "init") - await git.add(".") - await git.commit("initial commit") + gitExec(["git", "-C", dir, "add", "."]) + gitExec(["git", "-C", dir, "commit", "-m", "initial commit"]) return dir } @@ -51,31 +58,18 @@ async function changedFiles(cwd: string): Promise { /** Create a temp repo with a bare origin remote so origin/ refs exist. */ async function createTempRepoWithOrigin(): Promise<{ bare: string; clone: string }> { - // Use a non-bare seed repo to control the initial branch name, then clone bare - const seed = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-seed-")) - tempDirs.push(seed) - const seedGit = simpleGit(seed) - await seedGit.init() - await seedGit.addConfig("user.email", "test@test.com") - await seedGit.addConfig("user.name", "Test") - await fs.writeFile(path.join(seed, "README.md"), "init") - await seedGit.add(".") - await seedGit.commit("initial commit") - // Ensure branch is named "main" regardless of system default - const seedBranch = (await seedGit.revparse(["--abbrev-ref", "HEAD"])).trim() - if (seedBranch !== "main") await seedGit.raw(["branch", "-m", seedBranch, "main"]) - - // Clone to bare, then clone again as working copy const bare = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-bare-")) - tempDirs.push(bare) - await simpleGit().clone(seed, bare, ["--bare"]) - const clone = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-clone-")) - tempDirs.push(clone) - await simpleGit().clone(bare, clone) - const cloneGit = simpleGit(clone) - await cloneGit.addConfig("user.email", "test@test.com") - await cloneGit.addConfig("user.name", "Test") + tempDirs.push(bare, clone) + + gitExec(["git", "init", "--bare", "-b", "main", bare]) + gitExec(["git", "clone", bare, clone]) + gitExec(["git", "-C", clone, "config", "user.email", "test@test.com"]) + gitExec(["git", "-C", clone, "config", "user.name", "Test"]) + await fs.writeFile(path.join(clone, "README.md"), "init") + gitExec(["git", "-C", clone, "add", "."]) + gitExec(["git", "-C", clone, "commit", "-m", "initial commit"]) + gitExec(["git", "-C", clone, "push", "-u", "origin", "main"]) return { bare, clone } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 0171584bfd..6cc33b05b7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -2373,9 +2373,6 @@ const AgentManagerContent: Component = () => { onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)} terminalDestination={sideCtl.destination} terminalDestinationActive={() => sidePanel() === "terminal"} - terminalDestinationFocused={() => - sideCtl.destination() === "agentManager" && terms.sideFocusedId() !== undefined - } terminalKeybind={() => kb().showTerminal ?? ""} onTerminalDestinationOpen={() => { cancelAmbientSetup() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index 7dacbda4bc..359ad99e4c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -57,7 +57,6 @@ export interface TabBarProps { onToggleReview: () => void terminalDestination: () => TerminalDestination terminalDestinationActive: () => boolean - terminalDestinationFocused: () => boolean terminalKeybind: () => string onTerminalDestinationOpen: () => void onTerminalDestinationChoose: (destination: TerminalDestination) => void @@ -260,7 +259,6 @@ export const TabBar: Component = (props) => ( /** True while the embedded terminal panel is showing. */ active: Accessor - /** True while the embedded terminal owns DOM focus. */ - focused: Accessor keybind: Accessor onOpen: () => void onChoose: (destination: TerminalDestination) => void @@ -29,10 +27,6 @@ interface Props { export const TerminalDestinationButton: Component = (props) => { const { t } = useLanguage() - const title = () => - props.destination() === "agentManager" && props.focused() - ? t("agentManager.shortcuts.toggleTerminal") - : t("agentManager.tab.openTerminal") const item = (destination: TerminalDestination, label: string) => ( props.onChoose(destination)}> @@ -45,7 +39,7 @@ export const TerminalDestinationButton: Component = (props) => { ) return (
- + = (props) => { onMount(() => { const term = new Terminal({ - convertEol: true, + // PTY output already contains terminal line endings. Converting LF to + // CRLF here corrupts raw PTY output and is especially visible when a + // narrow terminal wraps and redraws the prompt. + convertEol: false, cursorBlink: true, cursorInactiveStyle: "outline", fontFamily: props.font.fontFamily, @@ -164,17 +167,11 @@ export const TerminalTab: Component = (props) => { const fit = new FitAddon() term.loadAddon(fit) term.open(host) - // Fit on the next frame — `host` might still have 0px dimensions - // during the initial layout pass otherwise. - requestAnimationFrame(() => { - try { - fit.fit() - } catch (err) { - // Host still detached at mount time. ResizeObserver will retry - // once layout kicks in. Logged so regressions don't hide. - log("initial fit() threw", err) - } - }) + // Unicode width must be configured before the first PTY bytes are parsed. + // Loading it later can leave already-wrapped graphemes with stale cell + // widths, which moves the cursor in narrow terminals. + term.loadAddon(new UnicodeGraphemesAddon()) + term.unicode.activeVersion = "15-graphemes" // Pass Agent Manager hotkeys through to the parent key handler so // ⌘T / ⌘⇧T / ⌘W / terminal cycling / ⌘⌥← still work while focused. @@ -356,11 +353,9 @@ export const TerminalTab: Component = (props) => { } const disposeData = term.onData(send) const disposeBinary = term.onBinary(send) - open(props.wsUrl) - // These addons are not needed to paint the initial prompt. Defer them - // until after the first frame so their startup work, especially the - // Unicode 15 width tables, does not delay the shell connection. + // until after the first frame so their startup work does not delay the + // shell connection. const loadAddons = () => { deferred = undefined if (closed) return @@ -375,16 +370,8 @@ export const TerminalTab: Component = (props) => { ) // OSC 52 clipboard support for shell programs such as tmux and neovim. term.loadAddon(new ClipboardAddon()) - // Use grapheme-aware width tables for newer emoji and ZWJ sequences. - term.loadAddon(new UnicodeGraphemesAddon()) - term.unicode.activeVersion = "15-graphemes" term.refresh(0, Math.max(0, term.rows - 1)) } - frame = requestAnimationFrame(() => { - frame = undefined - deferred = requestAnimationFrame(loadAddons) - }) - const restarted = (url: string) => { open(url) } @@ -395,10 +382,12 @@ export const TerminalTab: Component = (props) => { let resizeTimer: ReturnType | undefined let lastCols = term.cols let lastRows = term.rows - const syncSize = () => { - if (term.cols === lastCols && term.rows === lastRows) return + let synced = false + const syncSize = (force = false) => { + if (!force && synced && term.cols === lastCols && term.rows === lastRows) return lastCols = term.cols lastRows = term.rows + synced = true vscode.postMessage({ type: "agentManager.terminal.resize", terminalId: props.terminalId, @@ -406,6 +395,16 @@ export const TerminalTab: Component = (props) => { rows: term.rows, }) } + const fitNow = () => { + try { + fit.fit() + if (props.active) syncSize(true) + } catch (err) { + // Host still detached at mount time. ResizeObserver will retry + // once layout kicks in. Logged so regressions don't hide. + log("fit() threw", err) + } + } const ro = new ResizeObserver(() => { if (!props.active) return try { @@ -422,6 +421,16 @@ export const TerminalTab: Component = (props) => { resizeTimer = setTimeout(syncSize, RESIZE_DEBOUNCE_MS) }) ro.observe(host) + // Wait for the first committed layout before attaching the socket. This + // prevents the shell from emitting its first prompt at xterm's default + // 80 columns, which is most visible in a narrow side panel. + frame = requestAnimationFrame(() => { + frame = undefined + if (closed) return + fitNow() + open(props.wsUrl) + deferred = requestAnimationFrame(loadAddons) + }) // ---- Repaint recovery ---- // @@ -452,7 +461,7 @@ export const TerminalTab: Component = (props) => { if (!isRenderable()) return try { fit.fit() - syncSize() + syncSize(!synced) } catch (err) { // Layout not settled yet; ResizeObserver retries on next change. log("repaint fit() threw", err) @@ -485,6 +494,9 @@ export const TerminalTab: Component = (props) => { if (message.terminalId === props.terminalId && !ws) { term.options.fontFamily = message.font.fontFamily term.options.fontSize = message.font.fontSize + // Optimistic side terminals can fit before their backend PTY exists; + // force the first resize again once the created response arrives. + fitNow() scheduleRepaint() open(message.wsUrl) } 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 e85ef441e7..08f70bb699 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -3,11 +3,9 @@ * * Extracted from AgentManagerApp.tsx to keep that file under the * `max-lines` lint cap. Owns the destination preference plus the toggle - * semantics of the toolbar button / `Cmd/Ctrl+/` shortcut, so the - * embedded terminal behaves like the diff panel: press once to reveal, - * press again while focused to hide, and press while visible but unfocused - * to return focus to the shell. Hiding never kills the terminal — only the - * explicit close action does. + * semantics: the toolbar button toggles visibility, while `Cmd/Ctrl+/` + * reveals, focuses when unfocused, and hides when focused. + * Hiding never kills the terminal — only the explicit close action does. * * ## Destination state ownership * @@ -109,14 +107,15 @@ export function createSideTerminal(deps: SideTerminalDeps) { if (wasFocused) deps.refocus() } - const toggle = () => { + const toggle = (trigger: "keyboard_shortcut" | "tab_toolbar" = "keyboard_shortcut") => { if (deps.visible()) { - if (!deps.focusedId()) { + if (trigger === "keyboard_shortcut" && !deps.focusedId()) { deps.handlers.requestSide() return } + const was = deps.focusedId() !== undefined deps.hide() - handoff(true) + handoff(was) return } deps.handlers.requestSide() @@ -149,7 +148,7 @@ export function createSideTerminal(deps: SideTerminalDeps) { const target = destination() deps.track("terminal", trigger, { destination: target }) if (target === "agentManager") { - toggle() + toggle(trigger) return } deps.openVscode() 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 5b5442218b..dde770b4d4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -628,10 +628,32 @@ function newId(): string { } /** - * Build the close-terminal handler the main component wires to the - * close button. Picks the next visible tab before dropping the entry - * so focus flows naturally; notifies the extension last. + * Estimate initial terminal geometry from the current DOM container. + * Provides best-effort columns and rows so PTY spawn avoids the default + * 80-column line width before the first xterm fit pass commits. */ +function measureInitialDimensions( + placement: TerminalPlacement, + font: TerminalFont, +): { cols: number; rows: number } | undefined { + if (typeof document === "undefined") return undefined + const selector = + placement === "side" + ? ".am-side-terminal-layer, .am-side-terminal, .am-diff-panel-wrapper" + : ".am-terminal-layer, .am-detail-stack" + const host = document.querySelector(selector) as HTMLElement | null + const rect = host?.getBoundingClientRect() + if (!rect || rect.width <= 0 || rect.height <= 0) return undefined + const cellWidth = font.fontSize > 0 ? font.fontSize * 0.6 : 7.2 + const cellHeight = font.fontSize > 0 ? font.fontSize * 1.2 : 14.4 + const availableWidth = Math.max(0, rect.width - 30) + const availableHeight = Math.max(0, rect.height - 16) + return { + cols: Math.max(10, Math.floor(availableWidth / cellWidth)), + rows: Math.max(3, Math.floor(availableHeight / cellHeight)), + } +} + export function createTerminalHandlers(deps: TerminalHandlerDeps) { const activate = (id: string) => { deps.state.setActiveId(id) @@ -645,11 +667,15 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { const requestNew = () => { const sel = deps.getSelection() if (sel === null) return + const font = deps.getFont() + const dims = measureInitialDimensions("tab", font) deps.postMessage({ type: "agentManager.terminal.create", createId: newId(), placement: "tab", worktreeId: sel === deps.LOCAL ? null : sel, + cols: dims?.cols, + rows: dims?.rows, }) } @@ -659,12 +685,14 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { // project-namespaced state key and must not leak into the message. const sel = deps.getSelection() const id = newId() + const font = deps.getFont() + const dims = measureInitialDimensions("side", font) deps.state.beginSide(key, id) deps.state.add(key === deps.LOCAL ? null : key, { id, title: "Terminal", wsUrl: "", - font: deps.getFont(), + font, placement: "side", }) deps.state.setSideActive(key, id) @@ -674,6 +702,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { createId: id, placement: "side", worktreeId: sel === null || sel === deps.LOCAL ? null : sel, + cols: dims?.cols, + rows: dims?.rows, }) } diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 6dc0e4da1c..e8dafa01c5 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -311,8 +311,11 @@ const AppContent: Component = () => { // Set synchronously in the webview HTML by KiloProvider so it's available // before this component ever mounts (see buildWebviewHtml/_getHtmlForWebview). - // Dedicated single-purpose panels (Settings, Profile, Sub-Agent Viewer) set - // KILO_TOP_BAR = false since navigating away from them makes no sense. + // False for dedicated single-purpose panels (Settings, Profile, Sub-Agent + // Viewer) always, and for the Sidebar/"Open in Tab" outside Cursor — real + // VS Code's native title bar toolbar already covers those. Defaults to + // true only when unset entirely (e.g. Storybook, which doesn't render the + // real page HTML). const host = window as { KILO_TOP_BAR?: boolean; KILO_TOP_BAR_SURFACE?: string } const showTopBar = host.KILO_TOP_BAR !== false const topBarSurface = host.KILO_TOP_BAR_SURFACE ?? "sidebar_title" diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 075b925b73..da9e7789a8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1239,6 +1239,11 @@ export const PromptInput: Component = (props) => { pendingId, context, origin ?? null, + { + agent: matched.agent, + model: matched.model, + variant: matched.variant, + }, ) } else { session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data, origin ?? null) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SidebarTopBar.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SidebarTopBar.tsx index c031882e4d..0a0de70c92 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SidebarTopBar.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SidebarTopBar.tsx @@ -1,8 +1,10 @@ /** * Renders New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, and - * Settings inside the webview. VS Code's native `view/title` toolbar renders - * outside the webview DOM and disappears in the Secondary Side Bar with no way - * to detect or work around that — this bar guarantees the actions stay visible. + * Settings inside the webview, as a fallback for Cursor only (see isCursorHost() + * in src/utils.ts). Cursor's Secondary Side Bar support is unreliable for + * extension-contributed `view/title` toolbars, which render outside the webview + * DOM with no API to detect or work around the failure. Real VS Code renders the + * native toolbar fine everywhere, so it keeps using that instead of this bar. */ import { Component, For } from "solid-js" @@ -45,13 +47,12 @@ export const SidebarTopBar: Component = (props) => { type: "openAgentManager" | "openKiloClaw" | "openMarketplacePanel" | "openProfilePanel" | "openSettingsPanel", ) => vscode.postMessage({ type }) - const actions: (Action | "spacer")[] = [ + const actions: Action[] = [ { key: "newTask", codicon: "add", button: "new_task", run: () => props.onNewTask() }, { key: "history", codicon: "history", button: "history", run: () => props.onHistory() }, { key: "agentManager", codicon: "organization", button: "agent_manager", run: () => open("openAgentManager") }, { key: "kiloClaw", codicon: "comment-discussion", button: "kiloclaw", run: () => open("openKiloClaw") }, { key: "marketplace", codicon: "extensions", button: "marketplace", run: () => open("openMarketplacePanel") }, - "spacer", { key: "profile", codicon: "account", button: "profile", run: () => open("openProfilePanel") }, { key: "settings", codicon: "settings-gear", button: "settings", run: () => open("openSettingsPanel") }, ] @@ -60,7 +61,6 @@ export const SidebarTopBar: Component = (props) => {