diff --git a/.changeset/ask-to-code-agent-switch.md b/.changeset/ask-to-code-agent-switch.md new file mode 100644 index 00000000000..15c6b31ba59 --- /dev/null +++ b/.changeset/ask-to-code-agent-switch.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Keep the selected agent after switching from Ask to Code, and remind the model that previous Ask-mode restrictions no longer apply. diff --git a/.changeset/borderless-jetbrains-session.md b/.changeset/borderless-jetbrains-session.md new file mode 100644 index 00000000000..d4cc08d1eeb --- /dev/null +++ b/.changeset/borderless-jetbrains-session.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Remove borders around JetBrains prompt bubbles and markdown code surfaces, and round the collapsed card hover highlight and markdown code blocks to match the prompt bubble. diff --git a/.changeset/clean-agent-removal.md b/.changeset/clean-agent-removal.md new file mode 100644 index 00000000000..c1639c43f8a --- /dev/null +++ b/.changeset/clean-agent-removal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remove installed agents from every writable configuration source and report removal failures. diff --git a/.changeset/islands-button-borders.md b/.changeset/islands-button-borders.md new file mode 100644 index 00000000000..a112ed99811 --- /dev/null +++ b/.changeset/islands-button-borders.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix action button border artifacts in JetBrains Islands Light session dialogs. diff --git a/.changeset/jetbrains-expandable-hover-popups.md b/.changeset/jetbrains-expandable-hover-popups.md new file mode 100644 index 00000000000..0c425a30f1e --- /dev/null +++ b/.changeset/jetbrains-expandable-hover-popups.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Show the collapsed hover preview on more transcript cards: grep, glob, other tool calls, and to-dos now open the same popup that shell and diffs use. diff --git a/.changeset/jetbrains-session-colors.md b/.changeset/jetbrains-session-colors.md new file mode 100644 index 00000000000..85848f9a3ad --- /dev/null +++ b/.changeset/jetbrains-session-colors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refresh the session UI colors around three tunable roles — session background, code-block background (shared by code blocks, the prompt bubble, and the input), and foreground — and apply them correctly on first paint instead of only after switching themes. diff --git a/.changeset/jetbrains-session-secondary-text.md b/.changeset/jetbrains-session-secondary-text.md new file mode 100644 index 00000000000..3f08732f40f --- /dev/null +++ b/.changeset/jetbrains-session-secondary-text.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use a unified secondary text style for JetBrains session UI labels. diff --git a/.changeset/memory-show-preserve-text.md b/.changeset/memory-show-preserve-text.md new file mode 100644 index 00000000000..87a21b08ab1 --- /dev/null +++ b/.changeset/memory-show-preserve-text.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Typing extra text after the `/memory show` command (for example `/memory show my notes`) used to clear that text from the chat input when the command ran. The text now stays in the input so you don't lose what you typed. diff --git a/.changeset/memory-sse-json.md b/.changeset/memory-sse-json.md new file mode 100644 index 00000000000..122737bf606 --- /dev/null +++ b/.changeset/memory-sse-json.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop memory auto-save from failing on OpenAI-compatible providers that stream by default. diff --git a/.changeset/multirepo-changes.md b/.changeset/multirepo-changes.md deleted file mode 100644 index 5059084b44f..00000000000 --- a/.changeset/multirepo-changes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep the Changes chip and Git changes visible across tab switches in multi-repository workspaces. diff --git a/.changeset/opencode-v1-17-13-to-v1-18-0.md b/.changeset/opencode-v1-17-13-to-v1-18-0.md deleted file mode 100644 index 3b071f90f85..00000000000 --- a/.changeset/opencode-v1-17-13-to-v1-18-0.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Changes from opencode v1.17.13 to v1.18.0 upstream: - -- Core Improvements: Added a code mode MCP adapter for running confined orchestration scripts against connected MCP tools. -- Core Improvements: Hid the `execute` tool unless code mode is enabled. -- Core Improvements: Add a model-specific system prompt for Meta Muse Spark. -- Core Improvements: Updated Azure AI support for GPT-5.6. -- Core Bugfixes: Fixed paginated MCP tool catalogs losing tool metadata and output schema validation. -- Core Bugfixes: Preserved low reasoning effort for OpenRouter small-model variants instead of disabling it. -- Core Bugfixes: Fixed GitHub Copilot model routing to honor each model's advertised chat or responses endpoint. -- Core Bugfixes: Fixed session lists to match equivalent instance directories reliably. -- Core Bugfixes: Fixed Cerebras reasoning replay so earlier assistant reasoning is sent back in the provider-supported field. -- Core Bugfixes: Better classify Z.ai context-window overflow errors so oversized requests surface the right failure mode (@fengjikui) -- Core Bugfixes: Handle unavailable config directories more gracefully when reading config files -- Core Bugfixes: Exposed reasoning effort variants for Grok models. -- Core Bugfixes: Improved xAI prompt cache routing and PDF file support in Responses models. -- Core Bugfixes: Improved Meta model handling for reasoning variants and provider requests. -- Core Bugfixes: Prevent crashes and bad pricing data when GitHub Copilot returns models with a zero billing batch size. -- Core Bugfixes: Supported OpenAI pro reasoning mode. -- Core Bugfixes: Disabled response storage by default for xAI Responses. (@geraint0923) -- Core Bugfixes: Added OAuth support for Luna Responses Lite. -- Core Bugfixes: Switched to another available org after logging out in the console. -- Core Bugfixes: Used Codex context limits for GPT-5.6 over OAuth. (@nabilfreeman) -- Core Bugfixes: Removed an obsolete Codex workaround that could interfere with OpenAI Luna Responses Lite requests. -- TUI Bugfixes: Fixed spinner registration so loading indicators keep rendering across TUI surfaces. -- TUI Bugfixes: Forwarded CLI environment variables to the TUI worker. diff --git a/.changeset/opencode-v1-18-1-to-v1-18-13.md b/.changeset/opencode-v1-18-1-to-v1-18-13.md deleted file mode 100644 index 8c764a89da7..00000000000 --- a/.changeset/opencode-v1-18-1-to-v1-18-13.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Adopt upstream improvements from v1.18.1 through v1.18.13, including model compatibility, MCP reliability, and TUI enhancements. diff --git a/.changeset/prompt-bubble-outline.md b/.changeset/prompt-bubble-outline.md new file mode 100644 index 00000000000..b48361de99c --- /dev/null +++ b/.changeset/prompt-bubble-outline.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Always outline JetBrains prompt bubbles and use a rounder prompt bubble shape. diff --git a/.changeset/refine-jetbrains-session-spacing.md b/.changeset/refine-jetbrains-session-spacing.md new file mode 100644 index 00000000000..59fd75b31eb --- /dev/null +++ b/.changeset/refine-jetbrains-session-spacing.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine transcript card spacing, plan-ready styling, and diff scrollbar layout, and remove the stray stripe and gap left by empty messages at the top of turns. diff --git a/.changeset/remove-builtin-warpgrep.md b/.changeset/remove-builtin-warpgrep.md deleted file mode 100644 index 28b443a4fe1..00000000000 --- a/.changeset/remove-builtin-warpgrep.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": minor -"kilo-code": minor ---- - -Remove the built-in experimental Morph WarpGrep codebase search tool and ignore its retired configuration flag. diff --git a/.changeset/session-background-contrast.md b/.changeset/session-background-contrast.md new file mode 100644 index 00000000000..dba72ede1d6 --- /dev/null +++ b/.changeset/session-background-contrast.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains session prompts visible in themes where the panel and editor backgrounds match. diff --git a/.changeset/session-card-content-surfaces.md b/.changeset/session-card-content-surfaces.md new file mode 100644 index 00000000000..d4e6471ed12 --- /dev/null +++ b/.changeset/session-card-content-surfaces.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session card header clicks and align raised content backgrounds with the editor surface. diff --git a/.changeset/session-status-copy-overlay.md b/.changeset/session-status-copy-overlay.md new file mode 100644 index 00000000000..0bdebd2a647 --- /dev/null +++ b/.changeset/session-status-copy-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains session status and copy controls visually bounded to the transcript area. diff --git a/.changeset/slash-command-preserve-text.md b/.changeset/slash-command-preserve-text.md new file mode 100644 index 00000000000..58878bb4da4 --- /dev/null +++ b/.changeset/slash-command-preserve-text.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve existing text when selecting a slash command at the start of the chat input. Previously, typing `/new` or another slash command with text before the cursor would wipe the existing content entirely. diff --git a/.changeset/smooth-session-backdrop.md b/.changeset/smooth-session-backdrop.md new file mode 100644 index 00000000000..a521cd23205 --- /dev/null +++ b/.changeset/smooth-session-backdrop.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render the JetBrains session backdrop consistently and keep transcript hover backgrounds from getting stuck. diff --git a/.changeset/steady-migration-journal.md b/.changeset/steady-migration-journal.md deleted file mode 100644 index 47b2f219a7f..00000000000 --- a/.changeset/steady-migration-journal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Stop startup from crashing on a database migration that another Kilo process already applied diff --git a/.changeset/trim-inferred-variants.md b/.changeset/trim-inferred-variants.md new file mode 100644 index 00000000000..ca56b201b3c --- /dev/null +++ b/.changeset/trim-inferred-variants.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Use provider model catalogs instead of hardcoded model-name heuristics for reasoning variants. diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 10aac834f2b..421bc0e5caa 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -40,8 +40,8 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - # Restoring and extracting the ~1 GB cache took 2m23s on Windows, while a # kilocode_change - # fresh install took 1m27s. Keep Windows off this cache until that reverses. # kilocode_change + # Restoring the ~1 GB cache buys nothing on Windows even with Defender exclusions # kilocode_change + # (measured 83-102s Setup Bun with cache vs ~86s fresh install); keep Windows off it. # kilocode_change - name: Restore Bun dependencies if: runner.os != 'Windows' # kilocode_change id: bun-cache diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d57ca436e4b..e85876339dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,7 +64,10 @@ jobs: exit 0 fi echo 'general=true' >> "$GITHUB_OUTPUT" - echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT" + # kilocode_change - Windows runs 6 shards at KILO_TEST_CONCURRENCY=2. Measured: a + # 5-shard matrix was worse on both wall-clock (slowest job 9.3m vs 6.7m) and machine + # minutes (43 vs 37) — with 2 workers per shard, packing more work per shard loses. + echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT" # kilocode_change end unit: # kilocode_change start @@ -77,6 +80,12 @@ jobs: settings: ${{ fromJSON(needs.changes.outputs.settings) }} # kilocode_change runs-on: ${{ matrix.settings.host }} timeout-minutes: 45 # kilocode_change + # kilocode_change start - manual dispatches exist to validate real execution (soak runs, + # timing measurements); every turbo step in this job bypasses the cache on dispatch + # instead of replaying a same-commit hit and executing nothing. + env: + TURBO_FORCE: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }} + # kilocode_change end defaults: run: shell: bash @@ -87,6 +96,28 @@ jobs: run: echo "Only isolated product, documentation, or metadata files changed; general unit tests are unchanged." # kilocode_change end + # kilocode_change start - Defender real-time scanning taxes every process spawn and + # temp-file write on Windows; the suite spawns hundreds of test processes and builds + # temp git repos, so exclude the runner's work and temp paths up front. Best effort by + # design: on images where Defender is absent or locked down, the suite runs unchanged. + - name: Exclude runner work dirs from Defender scanning + if: matrix.settings.run && runner.os == 'Windows' + continue-on-error: true + shell: pwsh + run: | + $applied = 0 + foreach ($dir in @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, "$env:USERPROFILE\.bun")) { + if (-not $dir) { continue } + try { + Add-MpPreference -ExclusionPath $dir -ErrorAction Stop + $applied++ + } catch { + Write-Host "Defender exclusion failed for ${dir}: $($_.Exception.Message)" + } + } + Write-Host "Defender path exclusions applied: $applied" + # kilocode_change end + - name: Checkout repository if: matrix.settings.run # kilocode_change uses: actions/checkout@v6 # kilocode_change @@ -172,6 +203,16 @@ jobs: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - was Windows-only; the CLI now starts a watcher per instance, too heavy/racy for unit tests. Watcher tests opt back in. KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }} KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }} + # kilocode_change - cap parallelism on the 4-vCPU Windows runner. At the default + # min(4, cpus)=4, four heavy real-server test files share 4 vCPUs (~1 each) and blow + # their per-test timeouts; 2 gives each process real CPU headroom. Windows grows to + # 6 shards to absorb the lower per-shard parallelism. Linux/macOS (not timeout + # offenders; macOS is a single unsharded job) keep the default. + KILO_TEST_CONCURRENCY: ${{ matrix.settings.os == 'windows' && '2' || '' }} + # kilocode_change - 600s on every OS: the deadline guards against hangs, and a + # saturated shard can legitimately hold a heavy real-subprocess file past 300s + # (seen on Linux: run-process passing every test, killed at the deadline twice). + KILO_TEST_FILE_TIMEOUT: "600000" # kilocode_change end # kilocode_change start @@ -189,6 +230,9 @@ jobs: - name: Upload unit artifacts if: always() && matrix.settings.run + # kilocode_change - diagnostics only: a runner-side network blip here must not + # fail a job whose tests already passed (seen as ECONNREFUSED on Blacksmith). + continue-on-error: true uses: actions/upload-artifact@v7 with: name: unit-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.run_attempt }} diff --git a/artifacts/glm52-rise-video/package.json b/artifacts/glm52-rise-video/package.json index 6e80d0e38e7..1cb5063cb47 100644 --- a/artifacts/glm52-rise-video/package.json +++ b/artifacts/glm52-rise-video/package.json @@ -21,5 +21,5 @@ "@types/react-dom": "^19.2.3", "typescript": "^5.8.2" }, - "version": "7.4.20" + "version": "7.4.22" } diff --git a/bun.lock b/bun.lock index 61566a75a48..a9b07c5f8e3 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/client": { "name": "@opencode-ai/client", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -56,7 +56,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "7.4.20", + "version": "7.4.22", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -70,7 +70,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.21", + "version": "7.4.22", "bin": { "opencode": "./bin/opencode", }, @@ -168,7 +168,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -182,7 +182,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "effect": "catalog:", }, @@ -194,7 +194,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -215,7 +215,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode-ai/httpapi-codegen", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -228,7 +228,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -251,7 +251,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -281,7 +281,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.82", @@ -317,7 +317,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.21", + "version": "7.4.22", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -327,7 +327,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -359,11 +359,11 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.20", + "version": "7.4.21", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -377,7 +377,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -392,7 +392,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -406,7 +406,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -444,7 +444,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -517,7 +517,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -534,7 +534,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -553,7 +553,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.21", + "version": "7.4.22", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -721,7 +721,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@ai-sdk/provider": "3.0.8", "@kilocode/sdk": "workspace:*", @@ -750,7 +750,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -764,7 +764,7 @@ }, "packages/protocol": { "name": "@opencode-ai/protocol", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@opencode-ai/schema": "workspace:*", "effect": "catalog:", @@ -777,7 +777,7 @@ }, "packages/schema": { "name": "@opencode-ai/schema", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "effect": "catalog:", }, @@ -789,7 +789,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "semver": "^7.6.3", }, @@ -800,7 +800,7 @@ }, "packages/sdk-next": { "name": "@opencode-ai/sdk-next", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -815,7 +815,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "cross-spawn": "catalog:", }, @@ -830,7 +830,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -845,7 +845,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -890,7 +890,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.21", + "version": "7.4.22", "devDependencies": { "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -915,7 +915,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -941,7 +941,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.21", + "version": "7.4.22", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index f14280ac3a7..76f329ab890 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,6 @@ "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "solid-js@1.9.12": "patches/solid-js@1.9.12.patch" }, - "version": "7.4.21", + "version": "7.4.22", "peerDependencies": {} } diff --git a/packages/client/package.json b/packages/client/package.json index 6dde2dfe57c..15fb5d561f5 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -38,5 +38,5 @@ "@typescript/native-preview": "catalog:", "effect": "catalog:" }, - "version": "7.4.21" + "version": "7.4.22" } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index a96ecb5d5ee..5e59d854bfb 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -261,6 +261,7 @@ export type SessionsListOutput = { readonly deletions: number readonly patch: string }> + readonly workspace?: "restored" | "snapshots-disabled" | "unavailable" } }> readonly cursor: { readonly previous?: string | null; readonly next?: string | null } @@ -323,6 +324,7 @@ export type SessionsCreateOutput = { readonly deletions: number readonly patch: string }> + readonly workspace?: "restored" | "snapshots-disabled" | "unavailable" } } }["data"] @@ -361,6 +363,7 @@ export type SessionsGetOutput = { readonly deletions: number readonly patch: string }> + readonly workspace?: "restored" | "snapshots-disabled" | "unavailable" } } }["data"] @@ -509,6 +512,7 @@ export type SessionsStageOutput = { readonly deletions: number readonly patch: string }> + readonly workspace?: "restored" | "snapshots-disabled" | "unavailable" } }["data"] @@ -1114,6 +1118,7 @@ export type SessionsHistoryOutput = { readonly deletions: number readonly patch: string }> + readonly workspace?: "restored" | "snapshots-disabled" | "unavailable" } } } @@ -1600,6 +1605,7 @@ export type SessionsEventsOutput = readonly deletions: number readonly patch: string }> + readonly workspace?: "restored" | "snapshots-disabled" | "unavailable" } } } diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 2ecf56ec8c2..16a34f331a4 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "7.4.20", + "version": "7.4.22", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index 085a70bef0e..fbc64130ae3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.21", + "version": "7.4.22", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 4e612809172..2a46d342f02 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,5 +1,5 @@ import path from "path" -import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { Context, Duration, Effect, Layer, Logger, Option, Schedule, Schema } from "effect" // kilocode_change import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Global } from "./global" @@ -12,6 +12,7 @@ import * as ModelsRefresh from "./kilocode/models-refresh" // kilocode_change import { EventV2 } from "./event" import { makeGlobalNode } from "./effect/app-node" import { httpClient } from "./effect/app-node-platform" +import { Observability } from "./observability" // kilocode_change export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -156,6 +157,7 @@ const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const events = yield* EventV2.Service + const loggers = yield* Effect.service(Logger.CurrentLoggers) // kilocode_change const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -259,6 +261,7 @@ const layer = Layer.effect( ).pipe( Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), Effect.ignore, + Effect.provideService(Logger.CurrentLoggers, loggers), // kilocode_change ) }) @@ -271,6 +274,12 @@ const layer = Layer.effect( }), ) -export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) +// kilocode_change start - capture file/OTLP loggers before the refresh fork +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, EventV2.node, httpClient, Observability.node], +}) +// kilocode_change end export * as ModelsDev from "./models-dev" diff --git a/packages/core/test/kilocode/models-dev-logger.test.ts b/packages/core/test/kilocode/models-dev-logger.test.ts new file mode 100644 index 00000000000..c67d583c6e3 --- /dev/null +++ b/packages/core/test/kilocode/models-dev-logger.test.ts @@ -0,0 +1,65 @@ +import { afterAll, beforeAll, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { mkdir, readFile, rm, writeFile } from "fs/promises" +import path from "path" +import * as TestConsole from "effect/testing/TestConsole" +import { it } from "../lib/effect" + +const ORIGINAL_MODELS_PATH = Flag.KILO_MODELS_PATH +const ORIGINAL_DISABLE_FETCH = Flag.KILO_DISABLE_MODELS_FETCH +const cache = Global.Path.cache +const log = Global.Path.log +const root = path.join(Global.Path.tmp, `models-logger-${process.pid}-${Math.random().toString(36).slice(2)}`) +const logs = path.join(root, "log") + +beforeAll(async () => { + Flag.KILO_MODELS_PATH = undefined + Flag.KILO_DISABLE_MODELS_FETCH = true + Global.Path.cache = root + Global.Path.log = logs + await mkdir(logs, { recursive: true }) + await writeFile(path.join(root, "models.json"), "{}") +}) + +afterAll(async () => { + Flag.KILO_MODELS_PATH = ORIGINAL_MODELS_PATH + Flag.KILO_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH + Global.Path.cache = cache + Global.Path.log = log + await rm(root, { recursive: true, force: true }) +}) + +const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("boom", { status: 500 }))), +) + +const layer = Layer.fresh( + AppNodeBuilder.build(ModelsDev.node, [[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, client)]]), +) + +async function logged() { + const file = path.join(logs, "opencode.log") + for (let i = 0; i < 50; i++) { + const text = await readFile(file, "utf8").catch(() => "") + if (text.includes("Failed to fetch models.dev")) return text + await Bun.sleep(10) + } + return await readFile(file, "utf8").catch(() => "") +} + +describe("ModelsDev catalog refresh logging", () => { + it.live("failed refresh logs to file without Effect defaultLogger", () => + Effect.gen(function* () { + yield* ModelsDev.Service.use((s) => s.refresh(true)).pipe(Effect.provide(layer)) + const lines = yield* TestConsole.logLines + expect(lines.join("\n")).not.toContain("Failed to fetch models.dev") + expect(yield* Effect.promise(logged)).toContain("Failed to fetch models.dev") + }), + ) +}) diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index d44cffd849e..8bca015c253 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -3,3 +3,13 @@ import path from "path" process.env.KILO_DB = ":memory:" process.env.KILO_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json") process.env.KILO_DISABLE_MODELS_FETCH = "true" + +// kilocode_change start - fail closed: core unit tests do not redirect XDG dirs, so KILO_DB +// is the only thing keeping them off the real ~/.local/share/kilo database. Verify the +// resolved path (env is read at flag import time, so this must stay after the env writes). +const { Database } = await import("../src/database/database") +const resolved = Database.path() +if (resolved !== ":memory:") { + throw new Error(`unit test preload: database path must resolve to ":memory:", got "${resolved}"`) +} +// kilocode_change end diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index cc31d9a2154..df8e25d9ce5 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.21", + "version": "7.4.22", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 24dcfc4a40b..7189856c302 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.21", + "version": "7.4.22", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 681146c5f91..4dc6f5c0f8f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.21" +version = "7.4.22" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.21/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.22/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.21/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.22/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.21/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.22/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.21/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.22/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.21/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.22/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ae76fde11ff..3eb770425a1 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.21", + "version": "7.4.22", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index c8a57e2a5ca..65c5b6bc0b9 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -20,5 +20,5 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" }, - "version": "7.4.21" + "version": "7.4.22" } diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 463b6c64d6f..71f04943e6c 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.21", + "version": "7.4.22", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 6950c675c19..c5f4049255c 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.4.21", + "version": "7.4.22", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/ai-providers/bedrock.md b/packages/kilo-docs/pages/ai-providers/bedrock.md index 63390566477..a526a24a38a 100644 --- a/packages/kilo-docs/pages/ai-providers/bedrock.md +++ b/packages/kilo-docs/pages/ai-providers/bedrock.md @@ -15,9 +15,9 @@ Kilo Code supports accessing models through Amazon Bedrock, a fully managed serv - **AWS Account:** You need an active AWS account. - **Bedrock Access:** You must request and be granted access to Amazon Bedrock. See the [AWS Bedrock documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started.html) for details on requesting access. - **Model Access:** Within Bedrock, you need to request access to the specific models you want to use (e.g., Anthropic Claude). -- **Install AWS CLI:** Use AWS CLI to configure your account for authentication +- **AWS CLI (profile authentication only):** If you plan to use an AWS profile, install AWS CLI and configure your credentials: ```bash - aws configure + aws configure ``` ## Getting Credentials @@ -27,7 +27,7 @@ You have three options for configuring AWS credentials: 1. **Bedrock API Key:** - Create a Bedrock-specific API key in the AWS Console. This is a simple service-specific authentication method. - See the [AWS documentation on Bedrock credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_bedrock.html) for instructions on creating an API key. -2. **AWS Access Keys (Recommended for Development):** +2. **AWS Access Keys:** - Create an IAM user with the necessary permissions (at least `bedrock:InvokeModel`). - Generate an access key ID and secret access key for that user. - _(Optional)_ Create a session token if required by your IAM configuration. @@ -39,20 +39,32 @@ You have three options for configuring AWS credentials: {% tabs %} {% tab label="VSCode" %} -Open **Settings** (gear icon) and go to the **Providers** tab to add AWS Bedrock. The extension uses the AWS credentials chain for authentication — configure your AWS credentials using the AWS CLI or environment variables before adding the provider. +1. Open **Settings** and select **Providers**. +2. Find **Amazon Bedrock** and select **Connect**. +3. Choose an authentication method: + - **AWS access keys:** Enter the AWS access key ID, AWS secret access key, optional AWS session token, and AWS region. + - **Bedrock API key:** Enter a Bedrock API key. +4. Select **Submit**. -The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. +The extension stores these credentials in Kilo's credential store, not in `kilo.json`. {% /tab %} {% tab label="CLI" %} -Bedrock uses the AWS credentials chain for authentication. Configure your AWS credentials using the AWS CLI or environment variables: +Bedrock supports a Bedrock API key or the AWS credentials chain. -**Environment variables:** +**Bedrock API key:** + +```bash +export AWS_BEARER_TOKEN_BEDROCK="your-bedrock-api-key" +``` + +**AWS access key environment variables:** ```bash export AWS_ACCESS_KEY_ID="your-access-key" export AWS_SECRET_ACCESS_KEY="your-secret-key" +export AWS_SESSION_TOKEN="your-session-token" # Optional export AWS_REGION="us-east-1" ``` @@ -60,6 +72,7 @@ Or use an AWS profile: ```bash aws configure --profile bedrock +export AWS_PROFILE="bedrock" ``` **Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`): diff --git a/packages/kilo-docs/pages/ai-providers/vertex.md b/packages/kilo-docs/pages/ai-providers/vertex.md index 3b294ef61ec..1f73ea0a547 100644 --- a/packages/kilo-docs/pages/ai-providers/vertex.md +++ b/packages/kilo-docs/pages/ai-providers/vertex.md @@ -15,30 +15,37 @@ Kilo Code supports accessing models through Google Cloud Platform's Vertex AI, a - **Google Cloud Account:** You need an active Google Cloud Platform (GCP) account. - **Project:** You need a GCP project with the Vertex AI API enabled. - **Model Access:** You must request and be granted access to the specific Claude models on Vertex AI you want to use. See the [Google Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#before_you_begin) for instructions. -- **Application Default Credentials (ADC):** Kilo Code uses Application Default Credentials to authenticate with Vertex AI. The easiest way to set this up is to: - 1. Install the Google Cloud CLI: [https://cloud.google.com/sdk/docs/install](https://cloud.google.com/sdk/docs/install) - 2. Authenticate using: `gcloud auth application-default login` -- **Service Account Key (Alternative):** Alternatively, you can authenticate using a Google Cloud Service Account key file. You'll need to generate this key in your GCP project. See the [Google Cloud documentation on creating service account keys](https://cloud.google.com/iam/docs/creating-managing-service-account-keys). +- **Service Account Key:** To connect the VS Code extension, generate a JSON key for a service account that can access Vertex AI. See the [Google Cloud documentation on creating service account keys](https://cloud.google.com/iam/docs/creating-managing-service-account-keys). +- **Application Default Credentials (CLI option):** To use ADC with the CLI, [install the Google Cloud CLI](https://cloud.google.com/sdk/docs/install) and run `gcloud auth application-default login`. ## Configuration in Kilo Code {% tabs %} {% tab label="VSCode" %} -Open **Settings** (gear icon) and go to the **Providers** tab to add GCP Vertex AI. The extension uses Google Application Default Credentials (ADC) for authentication — run `gcloud auth application-default login` before adding the provider. Set your project ID and region in the provider settings. +1. Open **Settings** and select **Providers**. +2. Find **Google Vertex AI** and select **Connect**. +3. Paste the complete service account JSON into **Service-account JSON**. +4. Enter a **Google Cloud project ID** only if you want to override the `project_id` in the JSON. +5. Enter the **Vertex AI location**, such as `us-central1` or `global`. +6. Select **Submit**. -The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. +The extension stores the service account JSON in Kilo's credential store, not in `kilo.json`. The JSON is visible while you edit it so you can verify the value before connecting. + +{% callout type="warning" %} +Treat service account JSON like a password. Do not share it or commit it to source control. +{% /callout %} {% /tab %} {% tab label="CLI" %} -Vertex AI uses Google Application Default Credentials (ADC) for authentication. Set up ADC using the Google Cloud CLI: +The following CLI setup uses Google Application Default Credentials (ADC). Authenticate with the Google Cloud CLI: ```bash gcloud auth application-default login ``` -Set your project and region as environment variables: +Set your project and location as environment variables: ```bash export GOOGLE_CLOUD_PROJECT="your-project-id" diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 9a4ec19735b..05a1d48b131 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -139,6 +139,7 @@ Options: -s, --session session id to continue [string] --fork fork the session when continuing (use with --continue or --session) [boolean] --cloud-fork fetch session from cloud and continue locally (use with --session) [boolean] + --worktree create (or reuse) a git worktree with this name and start kilo there [string] --prompt prompt to use [string] --agent agent to use [string] --auto auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false] diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png index 198c494d204..147132c268a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2bb93bd5830bcfd208615e64c8b2b88855d1faa97cd7980df9d95cfada533d3 -size 3561 +oid sha256:dd306e8bb95d7457ef3b504ff46c44dcfa6b273a316021e2f920db29c2035021 +size 4992 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png index 8638a7f0c33..2a77311d6e1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c49479d1fffe96d14300617d48be269e5bffe41bc50f555b1ee3fbb09d8919e1 -size 3896 +oid sha256:d77e49dab17d964344cd0ca5618b1593f38c3d09f8dffffc2dca892477c5ff5c +size 5328 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png index f5aab2bf95f..e77104a7c54 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f0e205d89dc56438971d53e7210f1de1b261e641666ccebf0b1a2f36c3811aa -size 4195 +oid sha256:aa3a5abf6d9488d8d14db03b89e1c569dc7fc8181ee337478705d0af8bde4ea0 +size 5624 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 717fb18a2bd..0d6f05a0900 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 05796d3b6ce..3c043908fb1 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index eaaca716404..f4e85fd925e 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 254d66698f3..7be270ee584 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -291,6 +291,28 @@ Rules: - Put user-visible strings in `*.properties` files. - Do not add decorative helper functions, wrappers, or defensive UI code unless they materially improve clarity or correctness. +### Session UI Background Strategy + +The chat session UI intentionally uses a single-backdrop model. Do not make every +container paint `SessionUiStyle.Colors.sessionBackground()` just because it sits +inside the session. This avoids fragile component-hierarchy coupling and prevents +theme-specific artifacts in transparent/rounded Swing painting. + +- `SessionRootPanel` is the primary opaque backdrop. It overrides `getBackground()` and returns `SessionUiStyle.Colors.sessionBackground()`. +- `sessionBackground()` follows the panel background, but when that equals the raised editor surface (`codeBlockBackground()`) — e.g. Islands Dark/Darcula, where panel and editor backgrounds are identical — it shifts by `SESSION_DELTA` via `UiStyle.Colors.contrast` (lighter in dark themes, darker in light) so the prompt bubble/input and other raised surfaces stay visible. This is a universal fallback, not a per-theme override. +- `SessionRootPanel.Blocker` is the only other session-background opaque panel. It also overrides `getBackground()` with `sessionBackground()` for modal blocking. +- Scroll panes, viewports, transcript layout panels, message lists, turn containers, wrapper panels, and card bodies should be non-opaque unless they intentionally paint a distinct surface. +- `applyStyle(style)` must not assign session-background colors. It may update fonts, foregrounds, editor colors, and other non-background styling. Background colors that must be dynamic should come from `getBackground()` or custom painting. +- Transcript card header hover is `getBackground()`-driven: keep an `isHovered` flag on the hover row/header, set or clear it from mouse enter/exit, and repaint only that row/header. Do not assign `row.background` or `header.background` on hover. +- Primary/secondary transcript card bodies are transparent by default. The actual content inside a body (code blocks, todo list, shell/tool output) is the raised surface: make that content component opaque and paint `SessionUiStyle.Colors.codeBlockBackground()` (the editor background), using its own insets so the fill covers the whole content. For `MdView`-backed bodies set `md.opaque = false` so the prose/body stays transparent while the code/output panes remain the opaque editor surface — do not make the whole body opaque. +- Rounded cards that paint their own surface (for example `RoundedContentPanel`-based question/login cards) should do that in custom painting or a `contentColor()` override, not by making every nested panel opaque. +- Standard platform buttons placed on custom-painted session cards should not force the card background. If a button sits on a rounded/custom-painted card, make it non-opaque when needed so Swing does not fill its rectangular bounds behind `DarculaButtonUI`'s rounded paint (notably visible in Islands Light). + +When adding a new session view, start with transparent containers and add opaque +painting only for components that are actual visual surfaces. If a component's +background must react to hover/theme state, prefer an override such as +`getBackground()` over writing `background = ...` during setup or `applyStyle`. + ### Swing Component Lifecycle Swing is retained-mode UI. For dynamic Swing surfaces such as session cards, transcript parts, hover rows, and collapsible panels, build a stable component tree once and then mutate existing components in response to model or interaction changes. @@ -303,6 +325,7 @@ Swing is retained-mode UI. For dynamic Swing surfaces such as session cards, tra - Derive expanded state from containment (e.g. `scroll.parent === root`) rather than maintaining an `open` boolean. - Derive hover state from the current header background or other component property rather than maintaining a `hover` boolean. - Expand/collapse should attach or detach the existing body component and update controls only if attachment changed. +- Expandable session cards extend `AbstractSessionPartView`, which binds click-to-toggle and hover across the whole header subtree automatically (including children added later). Do not re-bind header parts per view. A header control that must not toggle the card (file link, copy button, toolbar action) simply owns its own mouse listener and is skipped automatically. - Hover should update only the affected component (usually the header background) and repaint only that component when the effective color changed. - Lazy-create expensive bodies such as `JBTextArea`, `JBScrollPane`, markdown panes, and HTML panes on first expansion or first direct access. - Parent containers should refresh for add/remove/reorder operations, not automatically after every delegated child update or streaming delta. diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index fb124946136..5b7b36174ef 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -150,6 +150,18 @@ ## [Unreleased] +## [7.0.16] - 2026-08-14 + +### Added + +### Fixed +- Improve JetBrains chat readability with refreshed session surfaces, softer spacing, clearer prompt bubbles, and more consistent hover states. +- Make JetBrains CLI downloads more reliable by retrying transient failures and rate limits, and by avoiding authentication on public CLI asset fetches. + +### Changed +- Update the JetBrains plugin CLI pin to Kilo Core 7.4.22. +- Refresh Kilo Core with upstream provider, model variant, and session runtime updates. + ## [7.0.15] - 2026-08-10 ### Added diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 1c1181c34d1..cc41a6c65df 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -17,6 +17,7 @@ import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import java.io.ByteArrayOutputStream import java.io.File +import java.io.IOException import java.net.HttpURLConnection import java.net.URI import java.security.MessageDigest @@ -32,6 +33,8 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") private val JSON = Json { ignoreUnknownKeys = true } private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags" + private const val ATTEMPTS = 8 + private const val DELAY_MS = 5_000L } @get:Input @@ -156,6 +159,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { conn.readTimeout = 120_000 conn.instanceFollowRedirects = true conn.setRequestProperty("Accept", "application/vnd.github+json") + conn.setRequestProperty("User-Agent", "Kilo-JetBrains-Gradle") token.getOrNull() ?.trim() ?.takeIf { it.isNotEmpty() } @@ -200,13 +204,35 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { private fun download(url: String, file: File) { logger.lifecycle("Downloading pinned Kilo CLI from $url") + var failure: IOException? = null + for (attempt in 1..ATTEMPTS) { + try { + downloadOnce(url, file) + return + } catch (err: IOException) { + failure = err + if (file.exists() && !file.delete()) logger.warn("Failed to delete partial pinned Kilo CLI archive ${file.absolutePath}") + if (attempt == ATTEMPTS) break + logger.warn("Pinned Kilo CLI download failed (attempt $attempt/$ATTEMPTS), retrying: ${err.message}") + Thread.sleep((DELAY_MS * attempt).coerceAtMost(60_000L)) + } + } + throw GradleException("Failed to download pinned Kilo CLI after $ATTEMPTS attempts from $url", failure) + } + + private fun downloadOnce(url: String, file: File) { val conn = URI(url).toURL().openConnection() as HttpURLConnection conn.connectTimeout = 30_000 conn.readTimeout = 120_000 conn.instanceFollowRedirects = true + conn.setRequestProperty("Accept", "application/octet-stream") + conn.setRequestProperty("User-Agent", "Kilo-JetBrains-Gradle") try { val code = conn.responseCode - if (code !in 200..299) throw GradleException("Failed to download pinned Kilo CLI: HTTP $code from $url") + if (code !in 200..299) { + if (code == 429 || code in 500..599 || limited(conn, code)) throw IOException("HTTP $code from $url") + throw GradleException("Failed to download pinned Kilo CLI: HTTP $code from $url") + } conn.inputStream.use { input -> file.outputStream().use { output -> input.copyTo(output) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 5e5cf5db5b8..fd971f8a8c4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -102,7 +102,6 @@ import java.nio.file.Path import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingUtilities -import javax.swing.UIManager /** * Top-level session UI composition root. @@ -209,8 +208,6 @@ class SessionUi( return listOf(text) } } - private var editorTheme = style.editorScheme - private var colorTheme = UIManager.getLookAndFeel() private var wasBusy = false // Kept separate so a background stat refresh (turn end / revert) can supersede another refresh // but never cancel an in-flight user-initiated open. @@ -318,6 +315,7 @@ class SessionUi( private fun buildUi() { root = SessionRootPanel() + // Containers stay transparent over the single self-rendered session root backdrop. fileLinks = SessionFileLinks(workspace.directory, workspaces, cs, root, ::openUrl) SessionContextMenu.install(root, this) @@ -351,14 +349,9 @@ class SessionUi( ) } - overlay = SessionHoverCopyOverlay(root, this) - root.addOverlay(overlay) { pane, child -> - overlay.bounds(pane, child) - } + sessionContent = JPanel(BorderLayout()).apply { isOpaque = false } - sessionContent = JPanel(BorderLayout()) - - blankBody = JPanel(BorderLayout()) + blankBody = JPanel(BorderLayout()).apply { isOpaque = false } load = LoadingPanel() progressBody = load @@ -406,6 +399,10 @@ class SessionUi( header = SessionHeaderPanel(controller, this) { openBranchChanges() } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) + overlay = SessionHoverCopyOverlay(root, scroll.component, this) + root.addOverlay(overlay) { pane, child -> + overlay.bounds(pane, child) + } messageBody.onReflow = { on -> if (on && !opening) scroll.followTail() } scroll.onScroll = { overlay.clear() @@ -639,11 +636,7 @@ class SessionUi( private fun bindStyle() { addHierarchyListener { event -> if ((event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong()) == 0L) return@addHierarchyListener - if (!isShowing) { - popup.hideAll() - return@addHierarchyListener - } - applyStyleIfThemeChanged() + if (!isShowing) popup.hideAll() } val bus = ApplicationManager.getApplication().messageBus.connect(this) @@ -1010,29 +1003,15 @@ class SessionUi( if (disposed) return this.style = style selection.applyStyle(style) - editorTheme = style.editorScheme - colorTheme = UIManager.getLookAndFeel() - background = style.editorBackground - root.background = style.editorBackground - root.content.background = style.editorBackground - sessionContent.background = style.editorBackground - blankBody.background = style.editorBackground load.applyStyle(style) header.applyStyle(style) prompt.applyStyle(style) connection.applyStyle(style) scroll.applyStyle(style) + empty?.applyStyle(style) refresh() } - private fun applyStyleIfThemeChanged() { - if (disposed) return - val next = SessionEditorStyle.current() - val laf = UIManager.getLookAndFeel() - if (editorTheme === next.editorScheme && colorTheme == laf) return - applyStyle(next) - } - private fun openProfileSettings() { ShowSettingsUtil.getInstance().showSettingsDialog( project, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index cf7d6d43609..c62610fa40f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -40,6 +40,9 @@ internal class SessionScroll( border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + // Transparent over the self-rendered SessionUi backdrop; the message list paints its own. + isOpaque = false + viewport.isOpaque = false } internal val bar: JScrollBar get() = component.verticalScrollBar @@ -223,8 +226,6 @@ internal class SessionScroll( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { this.style = style - component.background = style.editorBackground - component.viewport.background = style.editorBackground syncIcon() messages.applyStyle(style) val view = component.viewport.view diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index b97edac917f..cb4976c7e6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -64,7 +64,7 @@ class ConnectionPanel( } private val label = JBLabel().apply { - foreground = UiStyle.Colors.weak() + foreground = SessionUiStyle.Text.Secondary.foreground() addMouseListener(click) } @@ -83,7 +83,7 @@ class ConnectionPanel( isOpaque = false lineWrap = true wrapStyleWord = true - foreground = UiStyle.Colors.fg() + foreground = SessionUiStyle.Colors.foreground() } private val scroll = JBScrollPane(details).apply { @@ -136,7 +136,7 @@ class ConnectionPanel( } private fun showConnecting() { - label.foreground = UiStyle.Colors.weak() + label.foreground = SessionUiStyle.Text.Secondary.foreground() label.text = KiloBundle.message("session.connection.connecting") detail = null expanded = false @@ -147,7 +147,7 @@ class ConnectionPanel( } private fun showDownloading(percent: Int, version: String?, platform: String?) { - label.foreground = UiStyle.Colors.weak() + label.foreground = SessionUiStyle.Text.Secondary.foreground() val pct = percent.coerceIn(0, 100) label.text = if (version != null && platform != null) { KiloBundle.message("session.connection.downloading.version", version, platform, pct) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt index e7c20d74956..c1b667bfcdd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel import com.intellij.util.ui.Centerizer @@ -33,7 +34,7 @@ class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget { else -> { label.text = KiloBundle.message("session.empty.loading") - label.foreground = UiStyle.Colors.weak() + label.foreground = SessionUiStyle.Text.Secondary.foreground() } } revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index 4d01bfa9010..dcae262e864 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -12,15 +12,14 @@ import ai.kilocode.client.session.ui.selection.hoverPlaceholder import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.base.PartHeader -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.EditFileChange import ai.kilocode.client.session.views.tool.POPUP_OPTS import ai.kilocode.client.session.views.tool.PatchBody import ai.kilocode.client.session.views.tool.setFont import ai.kilocode.client.session.views.tool.setForeground import ai.kilocode.client.session.views.tool.setIcon -import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.DiffBars import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle @@ -36,7 +35,7 @@ class ModifiedFilesView private constructor( private val selection: SessionSelection? = null, private val parts: Header = Header(), private val body: PatchBody = PatchBody(selection, openFile), -) : SecondarySessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget { +) : AbstractSessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget { override val contentId = CONTENT_ID private var style = SessionEditorStyle.current() @@ -56,7 +55,6 @@ class ModifiedFilesView private constructor( body.overflow = ::openDiffViewer parts.diff.addActionListener { openDiffViewer() } isVisible = false - bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars, parts.anchor) applyStyle(style) } @@ -113,12 +111,8 @@ class ModifiedFilesView private constructor( override fun copyText(): String? = null @RequiresEdt - override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded() || files.isEmpty()) return null - return HeaderPopupRequest(row, build = { buildPopup(files) }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "changes")) - } - } + override fun headerPopup(): HeaderPopupRequest? = + popup("tool", "changes", files.isNotEmpty()) { buildPopup(files) } @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { @@ -156,7 +150,7 @@ class ModifiedFilesView private constructor( } val panel = popup.mountFiles(files) popup.applyStyle(style) - return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) + return HeaderPopupBody(panel, owner, SessionUiStyle.Colors.codeBlockBackground(), SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) } private class Header { @@ -173,7 +167,7 @@ class ModifiedFilesView private constructor( leading(glyph) left(title) titleGap() - left(count, PartHeader.centered(bars), PartHeader.centered(anchor)) + left(count, PartHeader.centered(bars), anchor) } @RequiresEdt @@ -189,8 +183,8 @@ class ModifiedFilesView private constructor( setForeground(glyph, SessionUiStyle.View.Tool.completed()) setFont(title, style.boldEditorFont) setFont(count, style.transcriptFont) - setForeground(title, UiStyle.Colors.fg()) - setForeground(count, UiStyle.Colors.weak()) + setForeground(title, SessionUiStyle.Colors.foreground()) + setForeground(count, SessionUiStyle.Text.Secondary.foreground()) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 3f076254065..4a461abef58 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis @@ -17,6 +18,7 @@ import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Color /** * Progress footer rendered at the bottom of the session transcript while the @@ -44,13 +46,13 @@ class ProgressPanel( foreground = style.editorForeground } private val elapsed = JBLabel().apply { - foreground = UiStyle.Colors.weak() + foreground = SessionUiStyle.Text.Secondary.foreground() } private val spinner = JBLabel(AnimatedIcon.Default()) private val tick = clock.timer(1000) { syncElapsed() } init { - isOpaque = false + isOpaque = true isVisible = false border = JBUI.Borders.empty( UiStyle.Gap.sm(), @@ -82,6 +84,8 @@ class ProgressPanel( /** Exposed for test assertions. */ fun labelForeground() = label.foreground + override fun getBackground(): Color = SessionUiStyle.Colors.sessionBackground() + private fun onState(state: SessionState) { this.state = state when (state) { @@ -140,7 +144,7 @@ class ProgressPanel( this.style = style label.font = style.regularFont elapsed.font = style.regularFont - elapsed.foreground = UiStyle.Colors.weak() + elapsed.foreground = SessionUiStyle.Text.Secondary.foreground() if (state is SessionState.Busy) label.foreground = style.editorForeground revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index e1273bdfc11..27afdf26eb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.views.base.PartHeader @@ -170,10 +171,10 @@ class RevertBanner( override fun applyStyle(style: SessionEditorStyle) { card.applyStyle(style) title.font = style.headerFont - title.foreground = UiStyle.Colors.fg() + title.foreground = SessionUiStyle.Colors.foreground() progress?.applyStyle(style) hint.foreground = UIUtil.getLabelForeground() - notice.foreground = UIUtil.getContextHelpForeground() + notice.foreground = SessionUiStyle.Text.Secondary.foreground() rows.values.forEach { it.applyStyle() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertProgress.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertProgress.kt index f698aee023f..99a09023a0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertProgress.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertProgress.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import com.intellij.ui.AnimatedIcon @@ -36,7 +37,7 @@ class RevertProgress(onCancel: () -> Unit) : JPanel(), SessionEditorStyleTarget override fun applyStyle(style: SessionEditorStyle) { this.style = style label.font = style.regularFont - label.foreground = UiStyle.Colors.fg() + label.foreground = SessionUiStyle.Colors.foreground() cancel.font = style.regularFont revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionCodeScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionCodeScroll.kt new file mode 100644 index 00000000000..cde8a244245 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionCodeScroll.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.ui + +import com.intellij.ui.components.JBScrollPane +import java.awt.Graphics +import javax.swing.JComponent + +/** + * Raised code surface for scrollable tool bodies. Paints the shared code-block background rounded + * with the standard block arc — the same corners as the card header — and clips its content to that + * shape so the opaque viewport never squares off the corners. Views keep their own content insets; + * this only owns the rounded fill. + */ +internal open class SessionCodeScroll(view: JComponent) : JBScrollPane(view) { + + override fun isOpaque(): Boolean = false + + override fun paintComponent(g: Graphics) { + SessionSurface.fill(g, width, height) + super.paintComponent(g) + } + + override fun paintChildren(g: Graphics) { + SessionSurface.clipped(g, width, height) { super.paintChildren(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionContentPanel.kt new file mode 100644 index 00000000000..5af09867039 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionContentPanel.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import java.awt.Component + +/** + * Reusable body for an expanded session card. A transparent vertical column that stacks content + * surfaces (usually [SessionSurfacePanel] boxes) separated by the standard [SessionUiStyle.View.contentGap], + * followed by a transparent footer region where a view can add ambient components — for example an + * auto-approved note — that read on the session backdrop rather than on a raised surface. + * + * The panel itself paints nothing: content pieces bring their own opaque surface, and the footer + * stays transparent, so the session's single-backdrop strategy is preserved and views never + * duplicate background painting. + */ +class SessionContentPanel : Stack(StackAxis.VERTICAL, SessionUiStyle.View.contentGap()) { + + private val body = Stack.vertical(SessionUiStyle.View.contentGap()) + private val footer = Stack.vertical(SessionUiStyle.View.contentGap()).also { it.isVisible = false } + + init { + next(body) + next(footer) + } + + /** Adds a content piece (its own raised surface) below the previous one, separated by the standard gap. */ + fun content(component: Component): SessionContentPanel { + body.next(component) + return this + } + + /** Adds a transparent footer component below the content; the footer region appears only once used. */ + fun footer(component: Component): SessionContentPanel { + footer.next(component) + footer.isVisible = true + return this + } + + /** True while the footer region holds at least one component. */ + fun hasFooter(): Boolean = footer.componentCount > 0 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index c4b3d8e6f41..68f3c3b0e44 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -149,6 +149,7 @@ open class SessionLayoutPanel( pad: Insets = JBUI.emptyInsets(), ) : BorderLayoutPanel(), javax.swing.Scrollable { init { + isOpaque = false layout = SessionLayout(gap, pad) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 8e6edd1ed6d..23a2bb65196 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -96,7 +96,6 @@ class SessionMessageListPanel( val progress = ProgressPanel(model, parent) init { - isOpaque = true Disposer.register(parent, this) applyStyle(style) @@ -381,10 +380,13 @@ class SessionMessageListPanel( private fun syncReverted() { for ((id, view) in msgToView) { - view.isVisible = !model.isRevertedMessage(id) + view.setReverted(model.isRevertedMessage(id)) } + // Turn visibility tracks revert only, independent of a message being empty: an empty message + // hides its own row (MessageView.setReverted/syncVisibility), but the turn stays visible so its + // other content and modified-files card still render. for (view in turnViews.values) { - view.isVisible = view.messageIds().any { msgToView[it]?.isVisible == true } + view.isVisible = view.messageIds().any { !model.isRevertedMessage(it) } } } @@ -610,7 +612,6 @@ class SessionMessageListPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = style.editorBackground for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index ba03b2f1bb4..873c35f9c0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -1,16 +1,34 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.LayeredOverlayPanel +import java.awt.Color class SessionRootPanel( private val sessionOverlay: Overlay = Overlay(), private val sessionBlocker: Blocker = Blocker(), ) : LayeredOverlayPanel(overlay = sessionOverlay, blocker = sessionBlocker) { + init { + content.isOpaque = false + } + + override fun isOpaque(): Boolean { + return true + } + + override fun getBackground(): Color { + return SessionUiStyle.Colors.sessionBackground() + } + override val overlay: Overlay get() = sessionOverlay override val blocker: Blocker get() = sessionBlocker class Overlay : LayeredOverlayPanel.Overlay() - class Blocker : LayeredOverlayPanel.Blocker() + class Blocker : LayeredOverlayPanel.Blocker() { + override fun getBackground(): Color { + return SessionUiStyle.Colors.sessionBackground() + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionSurface.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionSurface.kt new file mode 100644 index 00000000000..64fc5fef9b5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionSurface.kt @@ -0,0 +1,45 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.geom.RoundRectangle2D + +/** + * Shared painting for raised, rounded session code surfaces. Every inner block (tool output, task + * rows, todo list, code panes) uses the same block arc as the card header hover, so a single helper + * keeps the fill and the rounded child clip identical everywhere. + */ +internal object SessionSurface { + + fun arc(): Int = JBUI.scale(SessionUiStyle.View.BLOCK_ARC) + + /** Fills the component bounds with [color], rounded to the block arc. */ + fun fill(g: Graphics, width: Int, height: Int, color: Color = SessionUiStyle.Colors.codeBlockBackground()) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = color + val arc = arc() + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + } + + /** Runs [paint] with the graphics clipped to the rounded block, keeping opaque content rounded. */ + inline fun clipped(g: Graphics, width: Int, height: Int, paint: (Graphics) -> Unit) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = arc().toFloat() + g2.clip(RoundRectangle2D.Float(0f, 0f, width.toFloat(), height.toFloat(), arc, arc)) + paint(g2) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionSurfacePanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionSurfacePanel.kt new file mode 100644 index 00000000000..ed6bea6f439 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionSurfacePanel.kt @@ -0,0 +1,29 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.RoundedContentPanel +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Graphics + +/** + * A raised, opaque session block. Paints the shared code-block background with the standard session + * block corner arc (the same arc as the card header hover) and no outline, so any view that needs an + * opaque background gets the same rounded chrome without re-implementing the painting. + * + * The panel carries no padding — content decides its own insets (most views add a left inset to line + * up with the header, while shell code and patch/diff blocks span the full width). Children are + * clipped to the rounded rectangle, so an opaque rectangular child never squares off the corners. + */ +open class SessionSurfacePanel : RoundedContentPanel(0, 0, 0, 0) { + + override fun contentColor(): Color = SessionUiStyle.Colors.codeBlockBackground() + + override fun outlineColor(): Color? = null + + override fun cornerArc(): Int = JBUI.scale(SessionUiStyle.View.BLOCK_ARC) + + override fun paintChildren(g: Graphics) { + SessionSurface.clipped(g, width, height) { super.paintChildren(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt index b5b3d3ebc3b..239debec5c0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt @@ -183,7 +183,7 @@ open class AttachmentCard( try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC) - g2.color = SessionUiStyle.View.Surface.bgColor() + g2.color = SessionUiStyle.Colors.codeBlockBackground() g2.fillRoundRect(0, 0, width, height, arc, arc) g2.color = SessionUiStyle.View.Outline.color() g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index f277884c261..50f1bada459 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -24,7 +24,6 @@ import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.ui.components.JBLabel import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout @@ -68,7 +67,7 @@ class EmptySessionPanel( private val feedback = EmptySessionFeedback(browse) private val welcomeLabel = JBLabel(welcomeHtml()).apply { - foreground = UIUtil.getContextHelpForeground() + foreground = SessionUiStyle.Text.Secondary.foreground() horizontalAlignment = JBLabel.CENTER setAllowAutoWrapping(true) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt index cac09b304f3..039fe94158a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt @@ -19,7 +19,6 @@ import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout import java.awt.Component @@ -42,7 +41,7 @@ internal class RecentsList( private var snapshot = HistoryActivitySnapshot() private val title = JBLabel(KiloBundle.message("session.empty.recent")).apply { - foreground = UIUtil.getContextHelpForeground() + foreground = SessionUiStyle.Text.Secondary.foreground() } internal val list = JBList(model).apply { @@ -180,8 +179,8 @@ internal class RecentsList( val over = selected || hover == index isOpaque = over background = if (over) list.selectionBackground else list.background - title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() + title.foreground = if (over) list.selectionForeground else SessionUiStyle.Colors.foreground() + time.foreground = if (over) list.selectionForeground else SessionUiStyle.Text.Secondary.foreground() title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" time.text = value?.let(HistoryTime::relative) ?: "" setBadge(value?.id?.let(snapshot.activity::get)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt index c8a101eb602..ff3e300e4d5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchChangesBadge.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack @@ -65,7 +66,7 @@ internal class BranchChangesBadge( fun applyStyle(style: SessionEditorStyle) { count.font = style.smallFont - count.foreground = UiStyle.Colors.weak() + count.foreground = SessionUiStyle.Text.Secondary.foreground() } override fun getPreferredSize(): Dimension { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt index 67939dacead..67f933b2c4a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.header import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.ContextUsage import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI @@ -41,9 +42,10 @@ internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.md(), 0)) { } fun applyStyle(style: SessionEditorStyle) { - background = style.editorBackground + val bg = SessionUiStyle.Colors.sessionBackground() + background = bg foreground = style.editorForeground - meter.background = style.editorBackground + meter.background = bg used.font = style.smallFont used.foreground = style.editorForeground limit.font = style.smallFont @@ -143,7 +145,7 @@ private class Meter : JComponent() { fun reservedColor(): Color = shade(0.28f) private fun shade(alpha: Float): Color { - val base = background ?: UiStyle.Colors.editorBackground() + val base = background ?: SessionUiStyle.Colors.sessionBackground() val grey = if (UiStyle.Colors.bright(base)) Color.BLACK else Color.WHITE return UiStyle.Colors.blend(base, grey, alpha) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 53dd1b6790e..73c78212d64 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -318,20 +318,21 @@ class SessionHeaderPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = style.editorBackground + val bg = SessionUiStyle.Colors.sessionBackground() + background = bg foreground = style.editorForeground - top.background = style.editorBackground + top.background = bg top.isOpaque = true top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm()) - centerGroup.background = style.editorBackground + centerGroup.background = bg centerGroup.isOpaque = true - right.background = style.editorBackground - changes.background = style.editorBackground - tokens.background = style.editorBackground - todoRow.background = style.editorBackground - todoBox.background = style.editorBackground - body.background = style.editorBackground - viewport.background = style.editorBackground + right.background = bg + changes.background = bg + tokens.background = bg + todoRow.background = bg + todoBox.background = bg + body.background = bg + viewport.background = bg title.font = style.boldFont title.foreground = style.editorForeground changes.applyStyle(style) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt index 615b86603b9..4ea33e1adb3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.mode import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons @@ -72,7 +73,7 @@ internal class ModePickerRenderer( ): JPanel { val focus = selected || list.hasFocus() || focused val fg = UIUtil.getListForeground(selected, focus) - val weak = if (selected) fg else UiStyle.Colors.weak() + val secondary = if (selected) fg else SessionUiStyle.Text.Secondary.foreground() background = list.background wrap.update(list, selected, focus) @@ -81,7 +82,7 @@ internal class ModePickerRenderer( desc.clear() desc.isVisible = value.description?.isNotBlank() == true value.description?.takeIf { it.isNotBlank() }?.let { - desc.append(it, SimpleTextAttributes(SimpleTextAttributes.STYLE_SMALLER, weak)) + desc.append(it, SimpleTextAttributes(SimpleTextAttributes.STYLE_SMALLER, secondary)) } badge.isVisible = value.deprecated icon.icon = icon(value) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt index b6541f267a6..d1599215f67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.model import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack @@ -32,10 +33,10 @@ internal class ModelDetailsPanel( private val toggle: (ModelPicker.Item) -> Unit, ) : JPanel(BorderLayout()), Disposable { private val empty = JBLabel(KiloBundle.message("model.picker.details.empty")).apply { - foreground = UIUtil.getContextHelpForeground() + foreground = SessionUiStyle.Text.Secondary.foreground() } private val title = JBLabel().apply { font = UiStyle.Fonts.bold() } - private val provider = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() } + private val provider = JBLabel().apply { foreground = SessionUiStyle.Text.Secondary.foreground() } private val star = JBLabel().apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) horizontalAlignment = JBLabel.CENTER @@ -241,8 +242,8 @@ private class RowsSection(title: String) { } private class DetailRow : JPanel(BorderLayout()) { - private val name = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() } - private val value = JBLabel().apply { foreground = UIUtil.getLabelForeground() } + private val name = JBLabel().apply { foreground = SessionUiStyle.Text.Secondary.foreground() } + private val value = JBLabel().apply { foreground = SessionUiStyle.Colors.foreground() } init { add(name, BorderLayout.WEST) @@ -372,13 +373,13 @@ private fun descriptionText(value: String): String = value private class TagStyle(private val bg: Color) : UiStyle.Badge.Style { override fun bg() = bg - override fun fg() = UiStyle.Colors.fg() + override fun fg() = SessionUiStyle.Colors.foreground() } private fun tagStyle(index: Int): UiStyle.Badge.Style { if (index % 4 == 0) return UiStyle.Badge.Secondary val bg = when (index % 4) { - 1 -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), UiStyle.Colors.fg(), 0.12f) + 1 -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), SessionUiStyle.Colors.foreground(), 0.12f) 2 -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), JBUI.CurrentTheme.Link.Foreground.ENABLED, 0.18f) else -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), UiStyle.Badge.Primary.bg(), 0.18f) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index 55d27071f72..73f53301b30 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui.model +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.picker.PickerListRenderer @@ -67,6 +68,7 @@ internal class ModelPickerRenderer private constructor( foreground: java.awt.Color, weak: java.awt.Color, ) { + val secondary = if (selected) weak else SessionUiStyle.Text.Secondary.foreground() parts.title.clear() val item = value.item if (item == null) { @@ -80,8 +82,8 @@ internal class ModelPickerRenderer private constructor( } val name = ModelText.parts(item) if (name.provider != null) { - parts.title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) - parts.title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak)) + parts.title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, secondary)) + parts.title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, secondary)) } parts.title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground)) @@ -90,7 +92,7 @@ internal class ModelPickerRenderer private constructor( parts.byokLabel.isVisible = item.byok parts.provider.isVisible = value.favorite parts.provider.text = item.providerName - parts.provider.foreground = weak + parts.provider.foreground = secondary parts.provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) val fav = item.key in favorites() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt index e83897797fe..650fc442de5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt @@ -16,14 +16,22 @@ internal interface SessionCopyTarget { fun copyText(): String? } +/** + * Reserves the trailing hover-action button's *width* in a card header so header content never sits + * under the button, without reserving its height. The button is a hover overlay, so letting it + * dictate the header height would make action cards (edit, modified files) taller than plain cards. + * Added to a horizontal header [ai.kilocode.client.ui.layout.Stack] uncentered, it stretches to the + * row height at layout; the zero preferred height marks it as an inline anchor so the overlay + * vertically centers the button on the header row (aligned with the change badge). + */ internal fun hoverPlaceholder(toolbar: JComponent): JComponent = object : JPanel() { init { isOpaque = false } - override fun getPreferredSize(): Dimension = Dimension(toolbar.preferredSize) + override fun getPreferredSize(): Dimension = Dimension(toolbar.preferredSize.width, 0) - override fun getMinimumSize(): Dimension = Dimension(toolbar.minimumSize) + override fun getMinimumSize(): Dimension = Dimension(toolbar.minimumSize.width, 0) - override fun getMaximumSize(): Dimension = Dimension(toolbar.maximumSize) + override fun getMaximumSize(): Dimension = Dimension(toolbar.maximumSize.width, Int.MAX_VALUE) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt index 954e3e6084d..e25b033939a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt @@ -18,6 +18,7 @@ import javax.swing.SwingUtilities internal class SessionHoverCopyOverlay( private val root: JComponent, + private val area: JComponent, parent: Disposable, ) : JPanel(null), Disposable { private var target: SessionCopyTarget? = null @@ -57,18 +58,36 @@ internal class SessionHoverCopyOverlay( if (visible.isEmpty) return Rectangle() val size = child.preferredSize val gap = JBUI.scale(4) + val limit = limit(pane) + if (limit.isEmpty) return Rectangle() if (item.copyToolbar != null) { val pt = SwingUtilities.convertPoint(anchor, Point(visible.x, visible.y), pane) - val x = (pt.x + visible.width - size.width).coerceIn(0, (pane.width - size.width).coerceAtLeast(0)) - val y = (pt.y + visible.height - size.height).coerceIn(0, (pane.height - size.height).coerceAtLeast(0)) + // A zero-height anchor is an inline header placeholder (edit/modified open-diff): center + // the floating button on the header row so it lines up with the change badge. A real-height + // anchor is a footer row (message/text copy): keep the button bottom-aligned inside it. + val inline = anchor.preferredSize.height == 0 + val offset = if (inline) (visible.height - size.height) / 2 else visible.height - size.height + val x = clamp(pt.x + visible.width - size.width, limit.x, limit.x + limit.width - size.width) + val y = clamp(pt.y + offset, limit.y, limit.y + limit.height - size.height) return Rectangle(x, y, size.width, size.height) } val pt = SwingUtilities.convertPoint(anchor, Point(visible.x + visible.width, visible.y), pane) - val x = (pt.x - size.width - gap).coerceIn(0, (pane.width - size.width).coerceAtLeast(0)) - val y = (pt.y + gap).coerceIn(0, (pane.height - size.height).coerceAtLeast(0)) + val x = clamp(pt.x - size.width - gap, limit.x, limit.x + limit.width - size.width) + val y = clamp(pt.y + gap, limit.y, limit.y + limit.height - size.height) return Rectangle(x, y, size.width, size.height) } + private fun limit(pane: JPanel): Rectangle { + if (!area.isShowing || area.parent == null) return Rectangle() + val pt = SwingUtilities.convertPoint(area.parent, area.location, pane) + return Rectangle(pt.x, pt.y, area.width, area.height) + } + + private fun clamp(value: Int, min: Int, max: Int): Int { + if (max < min) return min + return value.coerceIn(min, max) + } + override fun doLayout() { child.setBounds(0, 0, width, height) layout(child) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 66d4c18fef0..5e6a14d0084 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -23,8 +23,8 @@ import kotlin.math.roundToInt * Editor-specific fields ([editorFont], [editorForeground], [editorBackground]) are derived from the active editor color * scheme and are used for code/editor-rendered content. * - * UI font fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [headerFont], [hintFont], [regularFont], - * [boldFont], [smallFont]) come from [UiStyle.Fonts] and follow standard platform typography. Transcript fonts use the + * UI font fields ([transcriptFont], [smallEditorFont], [boldEditorFont], [headerFont], [regularFont], [boldFont], + * [smallFont]) come from [UiStyle.Fonts] and follow standard platform typography. Transcript fonts use the * editor size so the session body tracks editor zoom without adopting the editor family. */ data class SessionEditorStyle( @@ -38,7 +38,6 @@ data class SessionEditorStyle( val smallEditorFont: Font, val boldEditorFont: Font, val headerFont: Font, - val hintFont: Font, val regularFont: Font, val boldFont: Font, val smallFont: Font, @@ -133,7 +132,6 @@ data class SessionEditorStyle( smallEditorFont = uiFont(UiStyle.Fonts.small(), Font.PLAIN, small), boldEditorFont = uiFont(UiStyle.Fonts.regular(), Font.BOLD, size), headerFont = UiStyle.Fonts.header(), - hintFont = UiStyle.Fonts.hint(), regularFont = UiStyle.Fonts.regular(), boldFont = UiStyle.Fonts.bold(), smallFont = UiStyle.Fonts.small(), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 9a3af597cf7..ff81a6cd395 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -5,11 +5,72 @@ import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color +import java.awt.Font +import javax.swing.UIManager /** Static style tokens owned by the chat session UI. */ object SessionUiStyle { + /** + * The session palette is driven by three authored keys; everything else is derived: + * - [sessionBackground] paints the whole session backdrop (containers stay transparent over it). + * - [codeBlockBackground] is the single raised surface (code blocks, tool/shell output, prompt bubble, prompt input). + * - [foreground] is normal text and links; [Text.Secondary] owns secondary session text. + */ + object Colors { + /** + * Recess applied to the raised editor surface to derive a distinct backdrop. Used both when + * the panel-background key is missing and when the panel background is identical to the + * raised surface (e.g. Islands Dark/Darcula), so the prompt bubble/input and other raised + * surfaces still stand out. [UiStyle.Colors.contrast] lightens dark themes and darkens light + * themes, matching how generic themes separate the backdrop from editor content. + */ + private const val SESSION_DELTA = 8 + + /** + * Whole session backdrop: follows the panel (chrome) background, distinct from raised + * surfaces. When the panel background matches the raised surface — so raised surfaces would + * be invisible against it — the backdrop is shifted by [SESSION_DELTA] instead. + * + * Not a `namedColor`: `JBColor.namedColor` resolves through theme `"*"` wildcard rules by + * suffix (`name.endsWith("background")`), so any theme with a `*.background` rule would + * hijack this key and skip the fallback below. We read an exact override key ourselves and + * otherwise compute the backdrop, wrapped in `JBColor.lazy` so it re-resolves on LaF changes. + */ + fun sessionBackground(): Color = JBColor.lazy { + UIManager.getColor("Kilo.Session.background") ?: run { + val raised = codeBlockBackground() + val panel = UIManager.getColor("Panel.background") ?: raised + if (panel.rgb == raised.rgb) UiStyle.Colors.contrast(raised, SESSION_DELTA) else panel + } + } + + /** Single raised surface (code blocks, tool/shell output, prompt bubble, prompt input): the editor background. */ + fun codeBlockBackground(): Color = JBColor.namedColor( + "Kilo.Session.codeBlockBackground", + UiStyle.Colors.editorBackground(), + ) + + fun foreground(): Color = JBColor.namedColor( + "Kilo.Session.foreground", + UiStyle.Colors.fg(), + ) + } + + /** Text roles used by session UI labels and transcript chrome. */ + object Text { + object Secondary { + private const val BACKGROUND_BLEND = 0.35f + + fun foreground(): Color = JBColor.lazy { + UiStyle.Colors.blend(Colors.foreground(), Colors.sessionBackground(), BACKGROUND_BLEND) + } + + fun font(style: SessionEditorStyle): Font = style.regularFont + } + } + object Transcript { - fun bgColor(): Color = UiStyle.Colors.bg() + fun bgColor(): Color = Colors.sessionBackground() } /** Geometry for the transcript list and its scroll behavior. */ @@ -30,13 +91,29 @@ object SessionUiStyle { /** Shared tokens for individual transcript views and session views. */ object View { + /** + * Corner arc (unscaled) for raised session blocks: the user prompt bubble and the + * collapsed card hover fill. Scale with [JBUI.scale] at the call site. + */ + const val BLOCK_ARC = 8 + object Layout { const val GAP = 5 const val VERTICAL_PADDING = 7 + /** Header vertical padding for the compact card variant (e.g. reasoning). */ + const val COMPACT_VERTICAL_PADDING = 5 const val HORIZONTAL_PADDING = 12 const val BODY_EXTRA_HEIGHT = 16 } + /** + * Standard transparent inset separating an expanded card header from its content, and + * separating stacked content surfaces inside a [ai.kilocode.client.session.ui.SessionContentPanel]. + * Matches the gap between views in the transcript ([SessionLayout.GAP]), so a card's internal + * spacing reads the same as the spacing between cards. + */ + fun contentGap() = JBUI.scale(SessionLayout.GAP) + /** * Single source of truth for the spacing of every session-card header (see `PartHeader`). * Keep header gaps here so all cards stay aligned; do not hardcode header spacing elsewhere. @@ -69,13 +146,13 @@ object SessionUiStyle { internal const val HOVER_FILL_ALPHA = 0.10f object Surface { - fun bgColor(): Color = UiStyle.Colors.editorBackground() + fun bgColor(): Color = Colors.sessionBackground() - fun headerBgColor(): Color = UiStyle.Colors.editorBackground() + fun headerBgColor(): Color = Colors.sessionBackground() /** Subtle hover fill, softer than the session-view outline. */ fun headerHoverBgColor(): Color = JBColor.lazy { - UiStyle.Colors.blend(headerBgColor(), Outline.hoverColor(), HOVER_FILL_ALPHA) + UiStyle.Colors.blend(Colors.sessionBackground(), Outline.hoverColor(), HOVER_FILL_ALPHA) } } @@ -83,7 +160,7 @@ object SessionUiStyle { fun color(): Color = UiStyle.Colors.contentBorder() fun brightColor(): Color = JBColor.lazy { - UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) + UiStyle.Colors.contrast(Colors.sessionBackground(), BORDER_DELTA) } /** Subtle hover outline, stronger than the hover fill. */ @@ -96,20 +173,12 @@ object SessionUiStyle { /** Prompt input dimensions and chrome inside the session view. */ object Prompt { - /** - * Background of the prompt input and the transcript user-prompt bubble. Uses a dedicated - * theme key so the prompt surface can be restyled independently, defaulting to the - * code-fragment background so the prompt matches rendered code blocks. - */ - fun bgColor(style: SessionEditorStyle): Color = JBColor.namedColor( - "Kilo.Session.Prompt.Background", - UiStyle.Colors.codeBlockBackground(style.editorScheme), - ) + fun bgColor(_style: SessionEditorStyle): Color = Colors.codeBlockBackground() const val EDITOR_LINES = 1 const val EDITOR_CHROME = 16 const val SEND_BUTTON_SIZE = 24 - const val CORNER_ARC = 6 + const val CORNER_ARC = 10 const val FOCUS_WIDTH = 2 const val PANEL_VERTICAL_PADDING = 8 const val PANEL_HORIZONTAL_PADDING = 12 @@ -145,7 +214,7 @@ object SessionUiStyle { private const val SCRIM_ALPHA = 210 fun scrim(): Color = JBColor.lazy { - val bg = UiStyle.Colors.bg() + val bg = Colors.sessionBackground() Color(bg.red, bg.green, bg.blue, SCRIM_ALPHA) } @@ -155,7 +224,6 @@ object SessionUiStyle { /** Reasoning block preview sizing. */ object Reasoning { const val BODY_LINES = 5 - const val HEADER_VERTICAL_PADDING = 5 const val BODY_VERTICAL_PADDING = 4 const val BODY_HORIZONTAL_PADDING = 8 } @@ -220,11 +288,11 @@ object SessionUiStyle { */ const val DIFF_MAX_LINES = 2_000 - fun pending(): Color = UiStyle.Colors.weak() + fun pending(): Color = Text.Secondary.foreground() - fun running(): Color = UiStyle.Colors.fg() + fun running(): Color = Colors.foreground() - fun completed(): Color = UiStyle.Colors.weak() + fun completed(): Color = Text.Secondary.foreground() fun error(): Color = UiStyle.Colors.errorLabelForeground() } @@ -249,7 +317,7 @@ object SessionUiStyle { val TOOL: Color = JBColor.namedColor("Kilo.Session.Timeline.Tool", Color(0x00, 0x7a, 0xcc)) val SUCCESS: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground()) val ERROR: Color = JBColor.namedColor("Kilo.Session.Timeline.Error", UIUtil.getErrorForeground()) - val TEXT: Color = JBColor.namedColor("Kilo.Session.Timeline.Text", UIUtil.getContextHelpForeground()) + val TEXT: Color = Text.Secondary.foreground() val STEP: Color = JBColor.namedColor("Kilo.Session.Timeline.Step", JBColor.border()) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index 244d5c94481..64745764fe6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -29,7 +29,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par override val contentId: String = compaction.id private val text = JBLabel(KiloBundle.message("session.part.compaction")).apply { - foreground = UiStyle.Colors.weak() + foreground = SessionUiStyle.Text.Secondary.foreground() horizontalAlignment = SwingConstants.CENTER border = JBUI.Borders.empty(0, UiStyle.Gap.lg()) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 710c0f93351..1fd304138e4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -35,7 +35,6 @@ import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Point import java.awt.Graphics @@ -96,6 +95,7 @@ class MessageView( private var wrap: PromptWrap? = null private var openDiff: SessionDiffOpener = { _, _, _ -> } private var sessionId: String? = null + private var reverted = false init { isOpaque = false @@ -108,6 +108,7 @@ class MessageView( if (isHidden(content)) continue addPart(content) } + syncVisibility() } fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { @@ -471,7 +472,11 @@ class MessageView( super.paintComponent(g) return } - paintPromptBox(g, this) + // Historical user prompts render their text as a plain child that relies on this surface fill, + // so paint it whenever the message has content. An empty user message (a bare turn anchor with + // no parts) lays out ~1px tall; painting its bubble there would leave a thin light stripe at + // the top of the turn, so skip it. + if (componentCount > 0) paintPromptBox(g, this) super.paintComponent(g) } @@ -479,26 +484,39 @@ class MessageView( val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) + val arc = JBUI.scale(SessionUiStyle.View.BLOCK_ARC) val pt = if (box === this) Point() else SwingUtilities.convertPoint(box, Point(), this) - val bg = SessionUiStyle.View.Prompt.bgColor(style) - g2.color = bg + g2.color = SessionUiStyle.View.Prompt.bgColor(style) g2.fillRoundRect(pt.x, pt.y, box.width, box.height, arc, arc) - // When the prompt shares the session background there is no fill contrast, so draw the - // outline to keep the bubble visible. - if (bg.rgb == style.editorBackground.rgb) { - val w = box.width - 1 - val h = box.height - 1 - g2.color = SessionUiStyle.View.Outline.color() - if (w > 0 && h > 0) g2.drawRoundRect(pt.x, pt.y, w, h, arc, arc) - } } finally { g2.dispose() } } + /** + * Mark this message as reverted (rolled back). A reverted message is hidden regardless of its + * content; the panel drives this from the model's revert state. + */ + @RequiresEdt + fun setReverted(value: Boolean) { + reverted = value + syncVisibility() + } + + /** + * An empty message (no rendered parts) would otherwise lay out as a ~1px row and add a stray gap + * at the top of its turn — a bare user turn anchor is the common case. Keep such a message present + * for lookup/streaming but invisible so [ai.kilocode.client.session.ui.SessionLayout] skips it and + * its gap; it reappears as soon as content arrives. Reverted messages stay hidden either way. + */ + @RequiresEdt + private fun syncVisibility() { + isVisible = componentCount > 0 && !reverted + } + @RequiresEdt private fun refresh() { + syncVisibility() revalidate() repaint() } @@ -623,7 +641,7 @@ class MessageView( private fun queue(onDelete: () -> Unit) = Stack.horizontal(UiStyle.Gap.sm()).also { row -> row.isOpaque = false row.next(JBLabel(KiloBundle.message("session.queued")).apply { - foreground = UIUtil.getContextHelpForeground() + foreground = SessionUiStyle.Text.Secondary.foreground() }) row.next(toolbarButton( ToolbarButtonAction( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index d24ffbe887c..0a68662efb8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -6,11 +6,13 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.client.session.openSessionLink import com.intellij.openapi.util.Disposer +import com.intellij.util.ui.JBUI import java.awt.BorderLayout class PlanExitView( @@ -32,6 +34,9 @@ class PlanExitView( init { layout = BorderLayout() isOpaque = false + // Prose, not a raised block, but still indented to the regular card header's leading edge so + // the plan-ready line aligns left with the tool/message views above and below it. + border = JBUI.Borders.empty(0, SessionUiStyle.View.Header.left(), 0, 0) Disposer.register(this, md) md.addLinkListener { openSessionLink(it, openFile, openUrl) } add(md.component, BorderLayout.CENTER) @@ -47,6 +52,9 @@ class PlanExitView( override fun applyStyle(style: SessionEditorStyle) { md.applyStyle(style) + // Plan-ready is transcript prose, not a raised block: stay transparent so it reads on the + // session backdrop like the assistant's other text. + md.opaque = false md.font = style.transcriptFont md.codeFont = style.editorFamily md.foreground = style.editorForeground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index c0d87158393..a48d89c0225 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -12,9 +12,8 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.base.PartHeader -import ai.kilocode.client.session.views.base.SecondarySessionPartView -import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory @@ -40,10 +39,11 @@ class ReasoningView( private val selection: SessionSelection? = null, private val parts: ReasoningParts = reasoningParts(selection), ) : - SecondarySessionPartView( + AbstractSessionPartView( parts.header, { parts.scroll(openFile, openUrl) }, expanded = reasoning.content.isNotBlank() && !reasoning.done, + compact = true, ) { override val contentId: String = reasoning.id @@ -70,16 +70,8 @@ class ReasoningView( private var pinned = false init { - row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), - SessionUiStyle.View.Header.left(), - JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), - SessionUiStyle.View.Header.right(), - ) - bindHeader(parts.title, parts.icon) applyStyle(style) if (bodyVisible()) syncBody() - syncBorder() sync() } @@ -87,20 +79,11 @@ class ReasoningView( override fun expand(): Boolean { val changed = super.expand() if (!changed) return false - syncBorder() syncBody() applyBodyStyle() return true } - @RequiresEdt - override fun collapse(): Boolean { - val changed = super.collapse() - if (!changed) return false - syncBorder() - return true - } - @RequiresEdt override fun update(content: Content) { if (content !is Reasoning) return @@ -179,13 +162,8 @@ class ReasoningView( internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 @RequiresEdt - override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded()) return null - val text = source.takeIf { it.isNotBlank() } ?: return null - return HeaderPopupRequest(row, build = { buildPopupBody(text) }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "part" to "reasoning")) - } - } + override fun headerPopup(): HeaderPopupRequest? = + popup("part", "reasoning", source.isNotBlank()) { buildPopupBody(source) } @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { @@ -203,7 +181,7 @@ class ReasoningView( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } @@ -224,20 +202,6 @@ class ReasoningView( return changed } - private fun syncBorder() { - if (isExpanded()) { - border = JBUI.Borders.customLine( - SessionUiStyle.View.Outline.color(), - 0, - SessionUiStyle.View.Outline.width(), - 0, - 0, - ) - return - } - border = JBUI.Borders.empty(0, 1, 0, 0) - } - private fun apply(md: MdView): Boolean { var changed = false val font = style.smallEditorFont.deriveFont(Font.ITALIC) @@ -245,8 +209,8 @@ class ReasoningView( md.font = font changed = md.codeFont != style.editorFamily || changed md.codeFont = style.editorFamily - changed = md.foreground.rgb != UiStyle.Colors.weak().rgb || changed - md.foreground = UiStyle.Colors.weak() + changed = md.foreground.rgb != SessionUiStyle.Text.Secondary.foreground().rgb || changed + md.foreground = SessionUiStyle.Text.Secondary.foreground() return changed } @@ -280,22 +244,22 @@ class ReasoningView( md.applyStyle(style) md.font = style.smallEditorFont.deriveFont(Font.ITALIC) md.codeFont = style.editorFamily - md.foreground = UiStyle.Colors.weak() - md.background = style.editorBackground + md.foreground = SessionUiStyle.Text.Secondary.foreground() + md.background = SessionUiStyle.Colors.codeBlockBackground() md.component.border = JBUI.Borders.empty() md.set(text) // The shared popup wrapper (HeaderPopupBody) provides the scroll pane, so pass the content // panel directly instead of nesting a second scroll pane here. val panel = TrackPanel().apply { isOpaque = true - background = style.editorBackground + background = SessionUiStyle.Colors.codeBlockBackground() border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), ) add(md.component, BorderLayout.CENTER) } - return HeaderPopupBody(panel, md, style.editorBackground) + return HeaderPopupBody(panel, md, SessionUiStyle.Colors.codeBlockBackground()) } private fun bodyMaxHeight(): Int { @@ -360,7 +324,7 @@ class ReasoningParts( } val panel = TrackPanel().apply { isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() + background = SessionUiStyle.Colors.codeBlockBackground() border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), @@ -370,8 +334,8 @@ class ReasoningParts( val scroll = JBScrollPane(panel).apply { border = JBUI.Borders.empty() isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - viewport.background = SessionUiStyle.View.Surface.bgColor() + background = SessionUiStyle.Colors.codeBlockBackground() + viewport.background = SessionUiStyle.Colors.codeBlockBackground() horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED } @@ -386,8 +350,8 @@ class ReasoningBody( ) private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { - val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } - val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() } + val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = SessionUiStyle.Text.Secondary.foreground() } + val icon = JBLabel(SessionViewIcons.brain).apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val header = PartHeader().apply { leading(icon) left(title) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 72c17a3466f..352035f33c9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.openSessionLink import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.views.base.PartView @@ -124,7 +125,7 @@ open class TextView( protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont - protected open fun styleBackground(style: SessionEditorStyle) = style.editorBackground + protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Colors.codeBlockBackground() protected fun refresh() { revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 23ba2b11197..e3608c349e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -1,23 +1,50 @@ package ai.kilocode.client.session.views.base +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color import java.awt.Component +import java.awt.Container import java.awt.Cursor +import java.awt.Font +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.ContainerAdapter +import java.awt.event.ContainerEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingUtilities +/** + * Base for every collapsible session card. A card is a header [row] plus an optional lazily built + * body. Collapsed, the card is just the header with a rounded hover fill. Expanded, the body is + * attached under the header and separated from it by a transparent [SessionUiStyle.View.contentGap] + * (the BorderLayout vgap) — the card draws no outline and no separator line, so content reads as + * standalone raised surfaces on the shared session backdrop. + */ abstract class AbstractSessionPartView( header: JComponent, private val makeBody: () -> JComponent, expanded: Boolean = false, private val expandable: Boolean = true, + private val compact: Boolean = false, ) : PartView() { constructor( @@ -25,11 +52,13 @@ abstract class AbstractSessionPartView( body: JComponent, expanded: Boolean = false, expandable: Boolean = true, - ) : this(header, { body }, expanded, expandable) + compact: Boolean = false, + ) : this(header, { body }, expanded, expandable, compact) protected val arrow = JBLabel() - protected val row = JPanel(BorderLayout(SessionUiStyle.View.Header.gap(), 0)) - private val bound = linkedSetOf() + protected val row = Row() + private val clickable = linkedSetOf() + private val watched = linkedSetOf() private var body: JComponent? = null private val click = object : MouseAdapter() { @@ -38,7 +67,10 @@ abstract class AbstractSessionPartView( toggle() } } - private val mouse = object : MouseAdapter() { + // Hover is tracked across the whole header subtree so leaving the row via any nested + // element (e.g. an unbound file link) still clears the hover fill. Swing only delivers + // mouseExited to the deepest component, so a single listener on the row is not enough. + private val pointer = object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { setHovered(true) } @@ -48,18 +80,35 @@ abstract class AbstractSessionPartView( setHovered(false) } } + private val nested = object : ContainerAdapter() { + override fun componentAdded(e: ContainerEvent) = watch(e.child) + override fun componentRemoved(e: ContainerEvent) = unwatch(e.child) + } init { - layout = BorderLayout() + // The vgap becomes the transparent inset between the header and the body once a body is + // attached; collapsed cards have only the NORTH row, so no gap shows. + layout = BorderLayout(0, SessionUiStyle.View.contentGap()) isOpaque = false + val pad = JBUI.scale( + if (compact) SessionUiStyle.View.Layout.COMPACT_VERTICAL_PADDING + else SessionUiStyle.View.Layout.VERTICAL_PADDING, + ) + row.border = JBUI.Borders.empty(pad, SessionUiStyle.View.Header.left(), pad, SessionUiStyle.View.Header.right()) row.add(header, BorderLayout.CENTER) row.add(arrow, BorderLayout.EAST) add(row, BorderLayout.NORTH) - bindHeader(row, header, arrow) + watch(row) if (expanded && expandable) add(body(), BorderLayout.CENTER) if (!expandable) syncExpandable(false) else syncArrow() } + /** + * The transparent header-to-content inset once expanded, else 0. Subclasses that cap their + * preferred height (`row.height + bodyHeight`) add this so the separator inset is accounted for. + */ + protected fun expandedGap(): Int = if (isExpanded()) SessionUiStyle.View.contentGap() else 0 + fun isExpanded(): Boolean = body?.parent === this fun toggle() { @@ -119,46 +168,170 @@ abstract class AbstractSessionPartView( return changed || detached || moved || icon } - protected fun bindHeader(vararg items: Component) { - items.forEach { bind(it) } - } - - protected fun unbindHeader(vararg items: Component) { - items.forEach { unbind(it) } - } - protected fun refresh() { revalidate() repaint() } - protected open fun hoverColor(value: Boolean): Color? = null + /** + * Standard collapsed hover-preview request anchored to the card header, or null when the card is + * not expandable, is already expanded, or has no [present] preview content. [kind]/[name] are the + * telemetry attributes (e.g. `"tool"`/`"bash"`, `"part"`/`"reasoning"`). [body] is built lazily + * when the popup actually shows; its disposable is owned by the popup controller and disposed on + * hide, so subclasses just build fresh, self-contained content. + */ + @RequiresEdt + protected fun popup(kind: String, name: String, present: Boolean, body: () -> HeaderPopupBody): HeaderPopupRequest? { + if (!expandable || isExpanded() || !present) return null + return HeaderPopupRequest(row, body) { + Telemetry.send("Header Popup Shown", mapOf("surface" to "session", kind to name)) + } + } + + /** + * Builds a markdown-backed [HeaderPopupBody] from [markdown]. The created [MdView] is owned by a + * fresh disposable that the popup controller disposes when the popup hides, so its editor is always + * released. [options] renders through an editor-only code block (shell/diff style); null renders + * prose. Height is bounded centrally by the popup panel to the same cap every popup shares; width + * uses the wide or normal popup cap. + */ + @RequiresEdt + protected fun markdownPopupBody( + style: SessionEditorStyle, + markdown: String, + wide: Boolean = true, + options: MdCodeBlockOptions? = null, + font: Font = style.editorFont, + foreground: Color = style.editorForeground, + link: ((String) -> Unit)? = null, + afterSet: (MdView) -> Unit = {}, + ): HeaderPopupBody { + val owner = Disposer.newDisposable("Header popup body") + val md = if (options != null) { + MdViewFactory.create(style, null, MdCodeBlockFactory.default(options)) + } else { + MdViewFactory.create(style, null) + } + Disposer.register(owner, md) + link?.let { l -> md.addLinkListener { l(it.href) } } + md.applyStyle(style) + md.font = font + md.foreground = foreground + md.background = SessionUiStyle.Colors.codeBlockBackground() + md.preBg = SessionUiStyle.Colors.codeBlockBackground() + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + md.set(markdown) + afterSet(md) + val width = if (wide) SessionUiStyle.View.Popup.WIDE_MAX_WIDTH else SessionUiStyle.View.Popup.MAX_WIDTH + return HeaderPopupBody(md.component, owner, SessionUiStyle.Colors.codeBlockBackground(), width) + } + + /** + * Builds a popup body around an already-created Swing [component]. The disposable owner is still + * routed through the same popup-controller lifecycle as markdown popups, so the content subtree is + * released when the popup hides even when the component itself has no disposable resources today. + */ + @RequiresEdt + protected fun componentPopupBody( + component: JComponent, + background: Color = SessionUiStyle.Colors.codeBlockBackground(), + wide: Boolean = true, + ): HeaderPopupBody { + val owner = Disposer.newDisposable("Header popup body") + if (component is Disposable) Disposer.register(owner, component) + val width = if (wide) SessionUiStyle.View.Popup.WIDE_MAX_WIDTH else SessionUiStyle.View.Popup.MAX_WIDTH + return HeaderPopupBody(component, owner, background, width) + } + + /** + * Header background, hovered or not. The header keeps the same rounded fill whether the card is + * collapsed or expanded — only the arrow toggles — because the card no longer draws an outline + * for the expanded body to meet. + */ + protected open fun hoverColor(value: Boolean): Color = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + + /** Corner arc (scaled) for the header hover fill. */ + protected open fun hoverArc(): Int = JBUI.scale(SessionUiStyle.View.BLOCK_ARC) override fun setHovered(value: Boolean) { hover?.invoke(this, value) - val color = hoverColor(value) ?: return - if (row.background?.rgb == color.rgb) return - row.background = color + val old = row.background + row.isHovered = value + val color = row.background + if (old.rgb == color.rgb) return row.repaint() } + protected inner class Row : JPanel(BorderLayout(SessionUiStyle.View.Header.gap(), 0)) { + var isHovered = false + + // Painted by paintComponent so the hover fill can be rounded; a rounded opaque fill would + // leave the corners stale, so the row stays non-opaque and the backdrop shows through. + override fun isOpaque(): Boolean = false + + override fun getBackground(): Color { + return hoverColor(isHovered) + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + val color = hoverColor(isHovered) + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = color + val arc = hoverArc() + if (arc > 0) g2.fillRoundRect(0, 0, width, height, arc, arc) else g2.fillRect(0, 0, width, height) + } finally { + g2.dispose() + } + } + } + private fun inside(e: MouseEvent): Boolean { val point = SwingUtilities.convertPoint(e.component, e.point, row) return row.contains(point) } - private fun bind(component: Component) { - if (bound.contains(component)) return - bound.add(component) - component.addMouseListener(click) - component.addMouseListener(mouse) + /** + * Attaches hover (and, for non-interactive elements, click-to-toggle) to [component] and its + * whole subtree, keeping up with later children. Hover covers everything so leaving the row via + * any nested element clears the fill. Click-to-toggle is bound only where the element does not + * already own a mouse listener, so controls like file links and copy buttons keep their own + * click action and do not also toggle the card. This means a control must install its own mouse + * listener before it joins the header subtree: a control that adds its listener later would + * already carry the toggle listener and would both act and toggle. All current call sites bind + * their listeners in constructors, so this ordering holds. + */ + private fun watch(component: Component) { + if (!watched.add(component)) return + if (component.mouseListeners.isEmpty()) { + component.addMouseListener(click) + clickable.add(component) + // Toggle-clickable header elements show the hand cursor while the card is toggleable. + // Arrow visibility mirrors that state, and syncExpandable resets the cursor when it flips. + if (arrow.isVisible) component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + component.addMouseListener(pointer) + if (component is Container) { + component.addContainerListener(nested) + component.components.forEach { watch(it) } + } } - private fun unbind(component: Component) { - if (!bound.remove(component)) return - component.removeMouseListener(click) - component.removeMouseListener(mouse) - component.cursor = Cursor.getDefaultCursor() + private fun unwatch(component: Component) { + if (!watched.remove(component)) return + if (clickable.remove(component)) { + component.removeMouseListener(click) + component.cursor = Cursor.getDefaultCursor() + } + component.removeMouseListener(pointer) + if (component is Container) { + component.removeContainerListener(nested) + component.components.forEach { unwatch(it) } + } } private fun body(): JComponent { @@ -169,7 +342,7 @@ abstract class AbstractSessionPartView( private fun syncCursor(cursor: Cursor): Boolean { var changed = false - bound.forEach { + clickable.forEach { if (it.cursor?.type != cursor.type) { it.cursor = cursor changed = true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 113a1c7b924..8c66ae6a4a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -92,8 +92,8 @@ class BaseQuestionView( isVisible = false } - private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true) - private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply { + private val headerText: JBTextArea = makeText("", SessionUiStyle.Colors.foreground(), bold = true) + private val descriptionText: JBTextArea = makeText("", SessionUiStyle.Text.Secondary.foreground(), bold = false).apply { isVisible = false } @@ -386,25 +386,19 @@ class BaseQuestionView( } private fun applyFont(area: JBTextArea, bold: Boolean) { - val font = if (bold) style.headerFont else style.hintFont + val font = if (bold) style.headerFont else SessionUiStyle.Text.Secondary.font(style) if (area.font != font) area.font = font } private fun makeButton(id: String, text: String): JButton { - val btn = object : JButton(text) { - init { - syncBackground() - } - - override fun updateUI() { - super.updateUI() - syncBackground() - } - - private fun syncBackground() { - background = SessionUiStyle.View.Surface.bgColor() - } - } + // Standard platform buttons: primary uses DEFAULT_STYLE_KEY (accent), the rest render as + // ordinary secondary buttons. DarculaButtonUI paints the rounded fill and border itself + // via isContentAreaFilled, so the button must be non-opaque. If it stays opaque, Swing + // first fills the rectangular bounds with the component background; in the Islands Light + // theme that color differs from the card surface and leaks as a stray frame around the + // rounded button (other themes happen to match, so they look fine). + val btn = JButton(text) + btn.isOpaque = false btn.addActionListener { actionHandlers[id]?.invoke() focus?.invoke() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 7f535048a70..04da3a81345 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel @@ -16,14 +17,14 @@ import com.intellij.ui.components.JBLabel class GenericView private constructor( content: Generic, private val label: JBLabel, -) : SecondarySessionPartView(label, JBLabel()) { +) : AbstractSessionPartView(label, JBLabel()) { constructor(content: Generic) : this(content, JBLabel("[${content.type}]")) override val contentId: String = content.id init { - label.foreground = UiStyle.Colors.weak() + label.foreground = SessionUiStyle.Text.Secondary.foreground() applyStyle(SessionEditorStyle.current()) syncExpandable(false) border = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt deleted file mode 100644 index b5ad67822b3..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ /dev/null @@ -1,49 +0,0 @@ -package ai.kilocode.client.session.views.base - -import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.util.ui.JBUI -import javax.swing.JComponent - -abstract class PrimarySessionPartView( - header: JComponent, - content: JComponent, - expanded: Boolean = false, - expandable: Boolean = true, -) : AbstractSessionPartView(header, content, expanded, expandable) { - init { - isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - row.isOpaque = true - row.background = SessionUiStyle.View.Surface.headerBgColor() - row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - SessionUiStyle.View.Header.left(), - JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - SessionUiStyle.View.Header.right(), - ) - syncBorder() - } - - override fun expand(): Boolean { - val changed = super.expand() - if (changed) syncBorder() - return changed - } - - override fun collapse(): Boolean { - val changed = super.collapse() - if (changed) syncBorder() - return changed - } - - override fun hoverColor(value: Boolean) = - if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() - - private fun syncBorder() { - if (isExpanded()) { - border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) - return - } - border = JBUI.Borders.empty(1) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt deleted file mode 100644 index a494b2d4460..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ /dev/null @@ -1,54 +0,0 @@ -package ai.kilocode.client.session.views.base - -import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.util.ui.JBUI -import javax.swing.JComponent - -abstract class SecondarySessionPartView( - header: JComponent, - content: () -> JComponent, - expanded: Boolean = false, - expandable: Boolean = true, -) : AbstractSessionPartView(header, content, expanded, expandable) { - - constructor( - header: JComponent, - content: JComponent, - expanded: Boolean = false, - expandable: Boolean = true, - ) : this(header, { content }, expanded, expandable) - init { - row.isOpaque = true - row.background = SessionUiStyle.View.Surface.headerBgColor() - row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - SessionUiStyle.View.Header.left(), - JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - SessionUiStyle.View.Header.right(), - ) - syncBorder() - } - - override fun expand(): Boolean { - val changed = super.expand() - if (changed) syncBorder() - return changed - } - - override fun collapse(): Boolean { - val changed = super.collapse() - if (changed) syncBorder() - return changed - } - - override fun hoverColor(value: Boolean) = - if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() - - private fun syncBorder() { - if (isExpanded()) { - border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) - return - } - border = JBUI.Borders.empty(1) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index 91f299579fb..1f6e59a51e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton @@ -171,8 +172,8 @@ class PermissionView( override fun applyStyle(style: SessionEditorStyle) { this.style = style card.applyStyle(style) - desc.font = style.hintFont - desc.foreground = UiStyle.Colors.weak() + desc.font = SessionUiStyle.Text.Secondary.font(style) + desc.foreground = SessionUiStyle.Text.Secondary.foreground() rules.applyStyle(style) md?.let { applyCodeStyle(it) } for (dv in diffViews) { @@ -276,7 +277,7 @@ class PermissionView( view.applyStyle(style) view.font = style.transcriptFont view.foreground = style.editorForeground - view.background = style.editorBackground + view.background = SessionUiStyle.Colors.codeBlockBackground() view.preBg = MdCommon.defaults(style).preBg view.codeFont = style.editorFamily view.component.border = JBUI.Borders.empty() @@ -353,8 +354,8 @@ class PermissionView( caret.isSelectionVisible = false lineWrap = true wrapStyleWord = true - foreground = UiStyle.Colors.weak() - font = style.hintFont + foreground = SessionUiStyle.Text.Secondary.foreground() + font = SessionUiStyle.Text.Secondary.font(style) border = JBUI.Borders.empty() isVisible = false } @@ -705,8 +706,8 @@ internal class PermissionRulesView( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { - hint.font = style.hintFont - hint.foreground = UiStyle.Colors.weak() + hint.font = SessionUiStyle.Text.Secondary.font(style) + hint.foreground = SessionUiStyle.Text.Secondary.foreground() field.applyStyle(style) } @@ -771,11 +772,11 @@ internal class PermissionRulesView( ed.setBorder(JBUI.Borders.empty()) ed.scrollPane.border = JBUI.Borders.empty() ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = style.editorBackground - ed.scrollPane.background = style.editorBackground + ed.backgroundColor = SessionUiStyle.Colors.codeBlockBackground() + ed.scrollPane.background = SessionUiStyle.Colors.codeBlockBackground() ed.scrollPane.isOpaque = true ed.scrollPane.viewport.isOpaque = true - ed.scrollPane.viewport.background = style.editorBackground + ed.scrollPane.viewport.background = SessionUiStyle.Colors.codeBlockBackground() ed.settings.isUseSoftWraps = false ed.settings.isAdditionalPageAtBottom = false ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER @@ -839,7 +840,7 @@ internal class PermissionRulesView( val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - val base = UiStyle.Colors.bg() + val base = SessionUiStyle.Colors.sessionBackground() g2.color = when { active -> UiStyle.Colors.blend(base, if (approve) UiStyle.Colors.addedForeground() else UiStyle.Colors.removedForeground(), 0.15f) else -> UiStyle.Colors.actionHoverBackground() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index 6cfa9164eba..c1db6a0f634 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -7,206 +7,167 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons -import ai.kilocode.client.session.views.base.PartView -import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.views.base.AbstractSessionPartView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI -import java.awt.BorderLayout import java.awt.Color import java.awt.Component -import java.awt.Cursor import java.awt.Dimension import java.awt.Font import java.awt.Rectangle -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.BoxLayout +import javax.swing.JComponent import javax.swing.JPanel -import javax.swing.SwingUtilities -class QuestionResultView(tool: Tool, private val selection: SessionSelection? = null) : PartView() { +class QuestionResultView( + tool: Tool, + selection: SessionSelection? = null, + private val parts: QuestionParts = questionParts(selection), +) : AbstractSessionPartView(parts.header, { parts.body }) { override val contentId: String = tool.id - private var result = QuestionResultParser.parse(tool) ?: QuestionResult(emptyList(), emptyList()) + private var result = parse(tool) private var style = SessionEditorStyle.current() - private val texts = mutableListOf>() - private val regs = mutableListOf() - - private val root = object : JPanel(BorderLayout()) { - override fun updateUI() { - super.updateUI() - isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - border = JBUI.Borders.empty(1) - } - } - private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { - override fun updateUI() { - super.updateUI() - isOpaque = true - background = SessionUiStyle.View.Surface.headerBgColor() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), - ) - } - } - private val glyph = JBLabel(SessionViewIcons.bubble) - private val title = JBLabel() - private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { - isOpaque = false - } - private var pane: JPanel? = null - - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { toggle() } - } - - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { setHovered(true) } - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHovered(false) - } - } init { - layout = BorderLayout() - isOpaque = false - - center.add(title, BorderLayout.WEST) - center.add(sub, BorderLayout.CENTER) - header.add(glyph, BorderLayout.WEST) - header.add(center, BorderLayout.CENTER) - header.add(arrow, BorderLayout.EAST) - root.add(header, BorderLayout.NORTH) - - listOf(header, glyph, title, sub, arrow, center).forEach { - it.addMouseListener(click) - it.addMouseListener(mouse) - it.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - } - header.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - - applyStyle(SessionEditorStyle.current()) - add(root, BorderLayout.CENTER) + applyStyle(style) syncLabels() - syncArrow() - syncBorder() + } + + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + parts.body.set(result.questions, result.answers) + return true } override fun update(content: Content) { if (content !is Tool) return - val next = QuestionResultParser.parse(content) ?: QuestionResult(emptyList(), emptyList()) + val next = parse(content) if (next == result) return result = next syncLabels() - syncBody() + if (isExpanded()) parts.body.set(result.questions, result.answers) refresh() } override fun applyStyle(style: SessionEditorStyle) { this.style = style - val t = setFont(title, style.boldFont) - val s = setFont(sub, style.smallFont) - val label = t || s - val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } - if (!label && !body) return - refresh() + var changed = setFont(parts.title, style.boldFont) + changed = setFont(parts.sub, style.smallFont) || changed + changed = parts.body.applyStyle(style) || changed + if (changed) refresh() } - fun toggle() { - resize?.invoke(this) { toggleBody() } ?: toggleBody() - syncArrow() - refresh() - } - - private fun toggleBody() { - if (isExpanded()) { - pane?.let { root.remove(it) } - } else { - root.add(body(), BorderLayout.CENTER) - } - syncBorder() - } - - fun isExpanded(): Boolean = pane?.parent === root - - fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ") + fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ") fun bodyText(): String = result.questions.mapIndexed { i, q -> val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty() listOf(q, joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") }).joinToString("\n") }.joinToString("\n") - fun bodyCreated(): Boolean = pane != null + fun bodyCreated(): Boolean = hasBody() - fun bodyFonts(): List = texts.map { it.first.font } + fun bodyFonts(): List = parts.body.fonts() - fun titleFont(): Font = title.font - fun subFont(): Font = sub.font + fun titleFont(): Font = parts.title.font - override fun dispose() { - disposeRegs() - texts.clear() - } + fun subFont(): Font = parts.sub.font + + override fun dispose() = parts.body.dispose() override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})" + private fun syncLabels() { + parts.title.text = KiloBundle.message("session.question.result.title") + val count = result.answers.count { it.isNotEmpty() } + parts.sub.text = KiloBundle.message("session.question.result.answered", count) + parts.sub.foreground = SessionUiStyle.Text.Secondary.foreground() + } + + private fun setFont(label: JBLabel, font: Font): Boolean { + if (label.font == font) return false + label.font = font + return true + } + companion object { fun canRender(tool: Tool): Boolean = QuestionResultParser.parse(tool) != null } +} - private fun body(): JPanel { - pane?.let { return it } - val panel = object : JPanel() { - override fun updateUI() { - super.updateUI() - isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - border = JBUI.Borders.compound( - JBUI.Borders.customLine( - SessionUiStyle.View.Outline.brightColor(), - SessionUiStyle.View.Outline.width(), - 0, - 0, - 0, - ), - JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), - ), - ) - } - }.apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - } - pane = panel - syncBody() - return panel +class QuestionParts( + val header: PartHeader, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val body: QuestionResultBody, +) + +private fun questionParts(selection: SessionSelection?): QuestionParts { + val glyph = JBLabel(SessionViewIcons.bubble) + val title = JBLabel() + val sub = JBLabel().apply { foreground = SessionUiStyle.Text.Secondary.foreground() } + val header = PartHeader().apply { + leading(glyph) + left(title) + titleGap() + left(sub) + } + return QuestionParts(header, glyph, title, sub, QuestionResultBody(selection)) +} + +private fun parse(tool: Tool) = QuestionResultParser.parse(tool) ?: QuestionResult(emptyList(), emptyList()) + +/** Lazy body for [QuestionResultView]: a transparent column of question/answer text areas. */ +class QuestionResultBody(private val selection: SessionSelection?) : JPanel() { + + private var questions: List = emptyList() + private var answers: List> = emptyList() + private var style = SessionEditorStyle.current() + private val texts = mutableListOf>() + private val regs = mutableListOf() + + init { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + // Transparent answers body: the base separates it from the header with the standard gap, so + // only content padding remains — no separator line. + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) } - private fun syncLabels() { - title.text = KiloBundle.message("session.question.result.title") - val count = result.answers.count { it.isNotEmpty() } - sub.text = KiloBundle.message("session.question.result.answered", count) - sub.foreground = UiStyle.Colors.weak() + fun set(questions: List, answers: List>) { + this.questions = questions + this.answers = answers + rebuild() } - private fun syncBody() { - val panel = pane ?: return - panel.removeAll() + fun applyStyle(style: SessionEditorStyle): Boolean { + this.style = style + return texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } + } + + fun fonts(): List = texts.map { it.first.font } + + fun dispose() { disposeRegs() texts.clear() + } - for ((i, q) in result.questions.withIndex()) { + private fun rebuild() { + removeAll() + disposeRegs() + texts.clear() + for ((i, q) in questions.withIndex()) { val row = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -214,21 +175,23 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } if (i > 0) row.border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) - val qText = makeText(q, UiStyle.Colors.weak(), false) + val qText = makeText(q, SessionUiStyle.Text.Secondary.foreground(), false) qText.alignmentX = Component.LEFT_ALIGNMENT qText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) row.add(qText) - val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty() + val joined = answers.getOrNull(i)?.joinToString(", ").orEmpty() val aText = makeText( joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") }, - UiStyle.Colors.fg(), + SessionUiStyle.Colors.foreground(), true, ) aText.alignmentX = Component.LEFT_ALIGNMENT row.add(aText) - panel.add(row) + add(row) } + revalidate() + repaint() } private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { @@ -285,51 +248,10 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = regs.clear() } - private fun syncArrow() { - arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed - } - - override fun setHovered(value: Boolean) { - hover?.invoke(this, value) - val color = - if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() - if (header.background?.rgb != color.rgb) { - header.background = color - header.repaint() - } - } - - private fun syncBorder() { - if (isExpanded()) { - root.border = JBUI.Borders.customLine( - SessionUiStyle.View.Outline.brightColor(), - SessionUiStyle.View.Outline.width(), - ) - return - } - root.border = JBUI.Borders.empty(1) - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - - private fun setFont(label: JBLabel, font: Font): Boolean { - if (label.font == font) return false - label.font = font - return true - } - private fun setFont(area: JBTextArea, bold: Boolean): Boolean { val font = if (bold) style.boldFont else style.regularFont if (area.font == font) return false area.font = font return true } - - private fun refresh() { - revalidate() - repaint() - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 8f103fa4dcf..6d539a593af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -185,7 +185,7 @@ class QuestionView( card.applyStyle(style) customEditor?.let { ed -> style.applyTranscriptToField(ed) - ed.background = style.editorBackground + ed.background = SessionUiStyle.Colors.codeBlockBackground() syncEditorHeight(ed) } val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } @@ -223,7 +223,7 @@ class QuestionView( val total = q.items.size val shown = minOf(idx + 1, total) summary.text = KiloBundle.message("session.question.summary", shown, total) - summary.foreground = UiStyle.Colors.weak() + summary.foreground = SessionUiStyle.Text.Secondary.foreground() summary.isVisible = total > 1 nav.isVisible = total > 1 topPanel.isVisible = total > 1 @@ -331,7 +331,7 @@ class QuestionView( layout = BoxLayout(this, BoxLayout.Y_AXIS) border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) } - val qText = text(item.question, UiStyle.Colors.weak()) + val qText = text(item.question, SessionUiStyle.Text.Secondary.foreground()) qText.alignmentX = Component.LEFT_ALIGNMENT row.add(qText) @@ -339,7 +339,7 @@ class QuestionView( val joined = answers.joinToString(", ") val answer = text( joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") }, - UiStyle.Colors.fg(), + SessionUiStyle.Colors.foreground(), true, ) answer.alignmentX = Component.LEFT_ALIGNMENT @@ -442,7 +442,7 @@ class QuestionView( addMouseListener(press) } - val label = text(KiloBundle.message("session.question.custom.label"), UiStyle.Colors.fg(), true) + val label = text(KiloBundle.message("session.question.custom.label"), SessionUiStyle.Colors.foreground(), true) label.alignmentX = Component.LEFT_ALIGNMENT label.addMouseListener(press) col.add(label) @@ -526,7 +526,7 @@ class QuestionView( } selection?.register(ed)?.let(regs::add) style.applyTranscriptToField(ed) - ed.background = style.editorBackground + ed.background = SessionUiStyle.Colors.codeBlockBackground() // Pre-fill with saved text. This call also forces lazy document creation so // that addDocumentListener can install on a non-null document immediately. @@ -700,13 +700,13 @@ class QuestionView( layout = if (center) GridBagLayout() else BoxLayout(this, BoxLayout.Y_AXIS) addMouseListener(press) } - val label = text(opt.label, UiStyle.Colors.fg(), true) + val label = text(opt.label, SessionUiStyle.Colors.foreground(), true) label.alignmentX = Component.LEFT_ALIGNMENT label.addMouseListener(press) col.add(label) if (opt.description.isNotBlank()) { - val desc = text(opt.description, UiStyle.Colors.weak()) + val desc = text(opt.description, SessionUiStyle.Text.Secondary.foreground()) desc.alignmentX = Component.LEFT_ALIGNMENT desc.addMouseListener(press) col.add(desc) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt index 63e2317606b..df6dad3186d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.views.todo import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.SessionSurface import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle @@ -36,6 +37,18 @@ class TodoListPanel( sync() } + // The todo list is a raised content surface painted with the editor background, rounded to the + // same block arc as the card header. It stays non-opaque so the rounded corners reveal the + // transparent card body behind them; children are clipped to the rounded shape. + override fun paintComponent(g: Graphics) { + SessionSurface.fill(g, width, height) + super.paintComponent(g) + } + + override fun paintChildren(g: Graphics) { + SessionSurface.clipped(g, width, height) { super.paintChildren(it) } + } + fun update(todos: List, hiddenBefore: Int = 0, hiddenAfter: Int = 0) { val size = todos.size != items.size items = todos @@ -52,8 +65,8 @@ class TodoListPanel( this.style = style prior.font = style.smallFont later.font = style.smallFont - prior.foreground = UiStyle.Colors.weak() - later.foreground = UiStyle.Colors.weak() + prior.foreground = SessionUiStyle.Text.Secondary.foreground() + later.foreground = SessionUiStyle.Text.Secondary.foreground() rows.forEachIndexed { index, row -> row.update(items[index], style) } syncHidden() } @@ -136,7 +149,7 @@ class TodoListPanel( text.font = style.regularFont text.foreground = when { !done -> style.editorForeground - else -> UiStyle.Colors.weak() + else -> SessionUiStyle.Text.Secondary.foreground() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 4db6e24d2ca..05d9c682d2a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -4,20 +4,23 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.base.PartHeader -import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.rpc.dto.TodoDto import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Font import javax.swing.JComponent class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : - PrimarySessionPartView(parts.header, parts.list, expanded = true) { + AbstractSessionPartView(parts.header, parts.list, expanded = true) { override val contentId = tool.id @@ -25,17 +28,9 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : private var style = SessionEditorStyle.current() init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.left, parts.right) - parts.list.border = JBUI.Borders.compound( - JBUI.Borders.customLine( - SessionUiStyle.View.Outline.color(), - SessionUiStyle.View.Outline.width(), - 0, - 0, - 0, - ), - JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()), - ) + // Transparent list body: the base separates it from the header with the standard gap, so no + // separator line is drawn here — only the content padding remains. + parts.list.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()) applyStyle(style) sync() } @@ -46,6 +41,21 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : sync() } + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + val data = rows(item) + val present = data.todos.isNotEmpty() || data.before > 0 || data.after > 0 + return popup("part", "todo", present) { buildPopup(data) } + } + + @RequiresEdt + private fun buildPopup(data: Rows) = componentPopupBody( + TodoListPanel(data.todos, data.before, data.after).apply { + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()) + applyStyle(style) + }, + ) + override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false @@ -73,13 +83,11 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : private fun sync() { parts.sub.text = subtitle(item) - val view = item.todoView - val compact = view?.mode == "compact" - val rows = if (compact) view.todos else item.todos + val data = rows(item) parts.list.update( - rows, - hiddenBefore = if (compact) view.hiddenBefore else 0, - hiddenAfter = if (compact) view.hiddenAfter else 0, + data.todos, + hiddenBefore = data.before, + hiddenAfter = data.after, ) syncExpandable(true) refresh() @@ -103,7 +111,7 @@ class TodoParts( private fun todoParts(): TodoParts { val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val sub = JBLabel().apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val header = PartHeader().apply { leading(glyph) left(title) @@ -120,6 +128,14 @@ private fun subtitle(tool: Tool): String { return "$done/$total" } +private data class Rows(val todos: List, val before: Int, val after: Int) + +private fun rows(tool: Tool): Rows { + val view = tool.todoView + if (view?.mode == "compact") return Rows(view.todos, view.hiddenBefore, view.hiddenAfter) + return Rows(tool.todos, 0, 0) +} + private fun setFont(component: JComponent, font: Font): Boolean { if (component.font == font) return false component.font = font diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt index e4dcb2d9151..7a1d858c5bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -3,10 +3,11 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer import com.intellij.util.concurrency.annotations.RequiresEdt @@ -19,7 +20,7 @@ abstract class BaseSearchToolView( private val selection: SessionSelection? = null, private val parts: ToolParts, private val repo: String? = null, -) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { +) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }) { override val contentId: String = tool.id @@ -34,8 +35,6 @@ abstract class BaseSearchToolView( protected abstract fun viewName(): String init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) - parts.targets.forEach { bindHeader(it) } applyStyle(style) sync() } @@ -53,7 +52,7 @@ abstract class BaseSearchToolView( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } @@ -66,6 +65,14 @@ abstract class BaseSearchToolView( if (changed) refresh() } + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + val md = toolBodyMarkdown(item) + return popup("tool", item.name, md.isNotBlank()) { + markdownPopupBody(style, md, options = POPUP_OPTS, foreground = bodyColor()) + } + } + @RequiresEdt fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) .filter { it.isNotBlank() } @@ -129,7 +136,7 @@ abstract class BaseSearchToolView( changed = setForeground(parts.glyph, color(item)) || changed changed = setText(parts.title, toolTitle(item)) || changed changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = setForeground(parts.sub, SessionUiStyle.Text.Secondary.foreground()) || changed changed = syncTargets() || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed @@ -148,7 +155,7 @@ abstract class BaseSearchToolView( val text = values.getOrNull(index) ?: "" changed = setVisible(label, text.isNotBlank()) || changed changed = setTargetText(label, text) || changed - changed = setForeground(label, UiStyle.Colors.fg()) || changed + changed = setForeground(label, SessionUiStyle.Colors.foreground()) || changed } return changed } @@ -176,7 +183,7 @@ abstract class BaseSearchToolView( return body.applyStyle(style) } - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else SessionUiStyle.Colors.foreground() private fun bodyMaxHeight(): Int { val body = parts.content ?: return 0 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt index 273b36d59a9..d3f748a7000 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/DiffOverflow.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import com.intellij.openapi.Disposable @@ -23,7 +24,7 @@ import javax.swing.JComponent @RequiresEdt internal fun diffOverflowPanel(open: () -> Unit): JComponent { val message = JBLabel(KiloBundle.message("diff.overflow.message")).apply { - foreground = UiStyle.Colors.weak() + foreground = SessionUiStyle.Text.Secondary.foreground() } val link = HyperlinkLabel(KiloBundle.message("diff.overflow.open")).apply { addHyperlinkListener { open() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 831bfb6ea19..6ef6e318682 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -16,8 +16,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PartHeader -import ai.kilocode.client.session.views.base.SecondarySessionPartView -import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle @@ -47,7 +46,7 @@ class EditToolView( private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, openFile), private var body: EditBody = editBody(tool, selection, openFile), -) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider, SessionCopyTarget { +) : AbstractSessionPartView(parts.header, { body.mount(tool) }), UiDataProvider, SessionCopyTarget { override val contentId: String = tool.id @@ -63,7 +62,7 @@ class EditToolView( ) private val diffAnchor = hoverPlaceholder(diff) private val filesTag = JBLabel().apply { - foreground = UiStyle.Colors.weak() + foreground = SessionUiStyle.Text.Secondary.foreground() font = JBFont.small() isVisible = false } @@ -75,11 +74,10 @@ class EditToolView( parts.left.next(parts.link) parts.left.next(filesTag) parts.left.next(PartHeader.centered(badge)) - parts.left.next(PartHeader.centered(diffAnchor)) - // parts.link is intentionally omitted: FileLinkLabel installs its own click handler that opens - // the file, and binding it here would also toggle the card on the same click (see ReadToolView, - // which likewise omits it). Header toggling still works via parts.left/row. - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot, filesTag, badge, diffAnchor) + parts.left.next(diffAnchor) + // The base binds click-to-toggle across the whole header subtree, skipping controls that own + // a mouse listener. parts.link (FileLinkLabel) installs its own click handler that opens the + // file, so it is skipped automatically and does not also toggle the card. applyStyle(style) sync() } @@ -128,7 +126,7 @@ class EditToolView( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = row.preferredSize.height + (body.panel()?.preferredSize?.height ?: 0) + val height = row.preferredSize.height + expandedGap() + (body.panel()?.preferredSize?.height ?: 0) return Dimension(size.width, minOf(size.height, height)) } @@ -197,13 +195,8 @@ class EditToolView( internal fun codeEditors(): List = body.codeEditors() @RequiresEdt - override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded()) return null - if (editDiff(item).isBlank()) return null - return HeaderPopupRequest(row, build = { buildPopupBody() }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "edit")) - } - } + override fun headerPopup(): HeaderPopupRequest? = + popup("tool", "edit", editDiff(item).isNotBlank()) { buildPopupBody() } @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { @@ -233,7 +226,7 @@ class EditToolView( val path = if (count > 1) null else editPath(item) changed = setFileTarget(parts, path, if (path == null) "" else tail(path)) || changed changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.link, SessionUiStyle.Colors.foreground()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed syncDiffAction(count) @@ -287,7 +280,7 @@ class EditToolView( // calls rebuild and sets its signature), so a follow-up update() here would be a no-op. val panel = popup.mount(item) popup.applyStyle(style) - return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) + return HeaderPopupBody(panel, owner, SessionUiStyle.Colors.codeBlockBackground(), SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) } override fun dumpLabel() = "EditToolView#$contentId(${labelText()})" @@ -342,10 +335,12 @@ private fun popupBody(tool: Tool, selection: SessionSelection?, openFile: Sessio private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody( MdCodeBlockOptions( - border = MdCodeBlockBorder.Bottom, + border = MdCodeBlockBorder.None, maxLines = SessionUiStyle.View.Tool.DIFF_LINES, verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editorOnly = true, + horizontalPadding = 0, + overlapScrollbar = true, ), selection, render = ::diffMarkdown, @@ -363,6 +358,8 @@ internal val POPUP_OPTS = MdCodeBlockOptions( border = MdCodeBlockBorder.None, verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editorOnly = true, + horizontalPadding = 0, + overlapScrollbar = true, ) /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt index 98c0fd67bbc..c612c1bde37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt @@ -19,7 +19,6 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane -import com.intellij.util.ui.NamedColorUtil import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Component @@ -184,7 +183,7 @@ class PatchBody( @RequiresEdt private fun header(file: EditFileChange): JComponent { val link = FileLinkLabel(openFile).apply { - foreground = UiStyle.Colors.fg() + foreground = SessionUiStyle.Colors.foreground() font = style.transcriptFont setTarget(file.path, tail(file.path)) isVisible = true @@ -195,10 +194,6 @@ class PatchBody( .next(DiffStatBadge(file.additions, file.deletions)) return JBUI.Panels.simplePanel(row).apply { isOpaque = false - border = JBUI.Borders.compound( - JBUI.Borders.customLineBottom(NamedColorUtil.getBoundsColor()), - JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING), - ) } } @@ -207,8 +202,8 @@ class PatchBody( md.applyStyle(style) md.font = style.editorFont md.foreground = style.editorForeground - md.background = style.editorBackground - md.preBg = style.editorBackground + md.background = SessionUiStyle.Colors.codeBlockBackground() + md.preBg = SessionUiStyle.Colors.codeBlockBackground() md.codeFont = style.editorFamily md.component.border = JBUI.Borders.empty() rows.getOrNull(views.indexOf(md))?.let { installGutter(md, it) } @@ -225,10 +220,12 @@ class PatchBody( private companion object { val DIFF_OPTS = MdCodeBlockOptions( - border = MdCodeBlockBorder.Bottom, + border = MdCodeBlockBorder.None, maxLines = SessionUiStyle.View.Tool.DIFF_LINES, verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editorOnly = true, + horizontalPadding = 0, + overlapScrollbar = true, ) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index 9ac9d158bea..f16bcea6b8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -8,7 +8,7 @@ import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI @@ -21,7 +21,7 @@ class ReadToolView( openFile: SessionFileOpener = { _, _ -> }, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, openFile), - ) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { + ) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }, expandable = false) { companion object { fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ @@ -33,9 +33,6 @@ class ReadToolView( private var style = SessionEditorStyle.current() init { - parts.text?.let { selection?.register(it, this) } - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) - parts.text?.text = preview(item) applyStyle(style) sync() } @@ -72,11 +69,11 @@ class ReadToolView( @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES @RequiresEdt - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + internal fun bodyFont() = style.transcriptFont @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() @RequiresEdt - internal fun bodyWrap() = parts.text?.lineWrap ?: false + internal fun bodyWrap() = parts.text?.lineWrap == true @RequiresEdt internal fun bodyEditor() = parts.content?.editor @RequiresEdt @@ -121,8 +118,8 @@ class ReadToolView( changed = setText(parts.title, title(item)) || changed changed = syncSubtitle() || changed changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed - changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.sub, SessionUiStyle.Colors.foreground()) || changed + changed = setForeground(parts.link, SessionUiStyle.Colors.foreground()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed parts.text?.let { changed = setForeground(it, bodyColor()) || changed } @@ -146,7 +143,7 @@ class ReadToolView( return true } - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else SessionUiStyle.Colors.foreground() private fun bodyMaxHeight(): Int { val text = parts.text ?: return 0 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 2bfca469314..14ce12abadc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -1,40 +1,42 @@ package ai.kilocode.client.session.views.tool -import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool -import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.session.ui.SessionContentPanel +import ai.kilocode.client.session.ui.SessionSurfacePanel import ai.kilocode.client.session.ui.popup.HeaderPopupBody import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView -import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.md.MdCodeBlockBorder -import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions -import ai.kilocode.client.ui.md.MdView -import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.ui.EditorTextField -import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import java.awt.Component import java.awt.Dimension import javax.swing.JComponent -import javax.swing.JPanel import javax.swing.ScrollPaneConstants +/** + * Renders `bash` tool calls. The header stays a compact title row; the expanded body is a + * [SessionContentPanel] holding the command and the output as two separate raised code surfaces + * (no "Command"/"Output" labels), with a transparent footer reserved for ambient notes such as an + * auto-approved reason. + */ class ShellToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool), - private val body: ToolMarkdownBody = shellBody(selection), -) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider { + private val body: ShellBody = ShellBody(selection), +) : AbstractSessionPartView(parts.header, { body.mount(tool) }), UiDataProvider { override val contentId: String = tool.id @@ -43,7 +45,6 @@ class ShellToolView( init { body.parent = this - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) applyStyle(style) sync() } @@ -52,7 +53,7 @@ class ShellToolView( selection?.provideCopy(sink) { body.markdown() ?: fallbackText() } } - private fun fallbackText() = ShellContent(item).body + private fun fallbackText() = shellBodyText(item) @RequiresEdt override fun expand(): Boolean { @@ -63,14 +64,6 @@ class ShellToolView( return true } - @RequiresEdt - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + (body.panel()?.preferredSize?.height ?: 0) - return Dimension(size.width, minOf(size.height, height)) - } - @RequiresEdt override fun update(content: Content) { if (content !is Tool) return @@ -98,7 +91,7 @@ class ShellToolView( fun errorText(): String = clean(item.error.orEmpty()) @RequiresEdt - fun bodyText(): String = ShellContent(item).body + fun bodyText(): String = shellBodyText(item) @RequiresEdt fun hasToggle(): Boolean = arrow.isVisible @@ -110,11 +103,14 @@ class ShellToolView( internal fun bodyVisible() = body.attached(this) @RequiresEdt - internal fun markdown() = body.markdown() ?: ShellContent(item).markdown + internal fun markdown() = body.markdown() ?: shellMarkdown(item) @RequiresEdt internal fun codeEditors(): List = body.codeEditors() + @RequiresEdt + internal fun scrolls(): List = body.scrolls() + @RequiresEdt internal fun commandFont() = codeEditors().firstOrNull()?.font ?: style.editorFont @@ -137,7 +133,10 @@ class ShellToolView( internal fun controlCount() = if (arrow.isVisible) 1 else 0 @RequiresEdt - internal fun mdComponent() = body.panel() + internal fun content(): SessionContentPanel? = body.panel() + + @RequiresEdt + internal fun surfaces() = body.surfaces() @RequiresEdt internal fun horizontalPolicy() = body.scrolls().firstOrNull()?.horizontalScrollBarPolicy @@ -145,11 +144,8 @@ class ShellToolView( @RequiresEdt override fun headerPopup(): HeaderPopupRequest? { - if (isExpanded()) return null - val cmd = command(item).takeIf { it.isNotBlank() } ?: return null - return HeaderPopupRequest(row, build = { buildPopupBody(cmd) }) { - Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "bash")) - } + val cmd = command(item) + return popup("tool", "bash", cmd.isNotBlank()) { buildPopupBody(cmd) } } @RequiresEdt @@ -174,7 +170,7 @@ class ShellToolView( changed = setText(parts.title, title(item)) || changed changed = setText(parts.sub, subtitle(item)) || changed changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = setForeground(parts.sub, SessionUiStyle.Text.Secondary.foreground()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed return changed @@ -183,29 +179,14 @@ class ShellToolView( private fun syncBody(): Boolean = body.update(item) @RequiresEdt - private fun buildPopupBody(cmd: String): HeaderPopupBody { - val md = MdViewFactory.create( + private fun buildPopupBody(cmd: String): HeaderPopupBody = + markdownPopupBody( style, - null, - MdCodeBlockFactory.default( - MdCodeBlockOptions( - border = MdCodeBlockBorder.None, - verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - editorOnly = true, - ), - ), - ) - md.applyStyle(style) - md.font = style.transcriptFont - md.foreground = style.editorForeground - md.background = style.editorBackground - md.preBg = style.editorBackground - md.codeFont = style.editorFamily - md.component.border = JBUI.Borders.empty() - md.set(popupMd(formatCommand(cmd))) - padPopup(md.component) - return HeaderPopupBody(md.component, md, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) - } + popupShellMarkdown(item, cmd), + options = SHELL_POPUP_OPTS, + font = style.transcriptFont, + foreground = style.editorForeground, + ) { padPopup(it.component) } override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" @@ -214,6 +195,121 @@ class ShellToolView( } } +/** + * The expanded shell body: a [SessionContentPanel] with the command and output as two independent + * markdown code surfaces. Each surface is a [SessionSurfacePanel] (rounded code-block background), + * so the two blocks read as distinct raised boxes separated by the standard transparent gap. Both + * are built lazily on first expansion and mutated in place afterwards. + */ +class ShellBody(private val selection: SessionSelection?) { + var parent: Disposable? = null + + private var panel: SessionContentPanel? = null + private var commandSurface: SessionSurfacePanel? = null + private var outputSurface: SessionSurfacePanel? = null + private var commandBody: ToolMarkdownBody? = null + private var outputBody: ToolMarkdownBody? = null + + @RequiresEdt + fun mount(tool: Tool): JComponent { + panel?.let { return it } + val owner = parent ?: error("Shell body has no parent") + val panel = SessionContentPanel() + val commandSurface = SessionSurfacePanel() + val outputSurface = SessionSurfacePanel() + val commandBody = shellSection(selection) { commandMarkdown(it) } + val outputBody = shellSection(selection) { outputMarkdown(it) } + commandBody.parent = owner + outputBody.parent = owner + commandSurface.addToCenter(commandBody.mount(tool)) + outputSurface.addToCenter(outputBody.mount(tool)) + panel.content(commandSurface).content(outputSurface) + this.panel = panel + this.commandSurface = commandSurface + this.outputSurface = outputSurface + this.commandBody = commandBody + this.outputBody = outputBody + update(tool) + applyStyle(SessionEditorStyle.current()) + return panel + } + + @RequiresEdt + fun created(): Boolean = panel != null + + @RequiresEdt + fun panel(): SessionContentPanel? = panel + + @RequiresEdt + fun surfaces(): List = listOfNotNull(commandSurface, outputSurface) + + @RequiresEdt + fun attached(host: Component): Boolean = panel?.parent === host + + @RequiresEdt + fun update(tool: Tool): Boolean { + val commandBody = commandBody ?: return false + val outputBody = outputBody ?: return false + val commandSurface = commandSurface ?: return false + val outputSurface = outputSurface ?: return false + var changed = false + changed = commandBody.update(tool) || changed + changed = outputBody.update(tool) || changed + changed = show(commandSurface, command(tool).isNotBlank()) || changed + changed = show(outputSurface, output(tool).isNotBlank() || !tool.error.isNullOrBlank()) || changed + return changed + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val commandBody = commandBody ?: return false + val outputBody = outputBody ?: return false + var changed = false + changed = commandBody.applyStyle(style) || changed + changed = outputBody.applyStyle(style) || changed + return changed + } + + @RequiresEdt + fun markdown(): String? { + if (panel == null) return null + return listOfNotNull(commandBody?.markdown(), outputBody?.markdown()) + .filter { it.isNotBlank() } + .joinToString("\n\n") + } + + @RequiresEdt + fun scrolls(): List = commandBody?.scrolls().orEmpty() + outputBody?.scrolls().orEmpty() + + @RequiresEdt + fun codeEditors(): List = commandBody?.codeEditors().orEmpty() + outputBody?.codeEditors().orEmpty() + + private fun show(surface: JComponent, visible: Boolean): Boolean { + if (surface.isVisible == visible) return false + surface.isVisible = visible + return true + } +} + +/** Editor-only code block used by the collapsed shell hover popup (uncapped; the popup scrolls). */ +private val SHELL_POPUP_OPTS = MdCodeBlockOptions( + border = MdCodeBlockBorder.None, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, +) + +private fun shellSection(selection: SessionSelection?, render: (Tool) -> String) = ToolMarkdownBody( + MdCodeBlockOptions( + border = MdCodeBlockBorder.None, + maxLines = SessionUiStyle.View.Tool.BODY_LINES, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + selection, + render = render, + font = SessionEditorStyle::transcriptFont, +) + private fun padPopup(root: JComponent) { root.components.filterIsInstance().forEach { pane -> val field = pane.viewport.view as? EditorTextField ?: return@forEach @@ -230,50 +326,42 @@ private fun padPopup(root: JComponent) { private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad) -private fun shellBody(selection: SessionSelection?) = ToolMarkdownBody( - MdCodeBlockOptions( - border = MdCodeBlockBorder.Bottom, - maxLines = 15, - verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - editorOnly = true, - ), - selection, - render = { ShellContent(it).markdown }, - font = SessionEditorStyle::transcriptFont, - chrome = ::styleShellHtml, -) +/** Command markdown for the shell command surface: the raw command in a `bash` fence, or empty. */ +private fun commandMarkdown(tool: Tool): String { + val cmd = command(tool) + if (cmd.isBlank()) return "" + return fenced(cmd, "bash") +} -/** Pads the left edge of shell section headers ("Command"/"Output") to line up with code text. */ -@RequiresEdt -private fun styleShellHtml(md: MdView) { - val root = md.component as? JPanel ?: return - root.components.filterIsInstance().forEach { - it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING) +/** Output markdown for the shell output surface: the raw stdout then any stderr, or empty. */ +private fun outputMarkdown(tool: Tool): String { + val out = output(tool) + val err = tool.error.orEmpty() + return buildString { + if (out.isNotBlank()) append(fenced(out, outputLang(out))) + if (err.isNotBlank()) { + if (isNotEmpty()) append("\n\n") + append(fenced(err, "ansi-stderr")) + } } } -private data class ShellContent( - val command: String, - val output: String, - val error: String, - val rawOutput: String = output, - val rawError: String = error, -) { - constructor(tool: Tool) : this( - command(tool), - clean(output(tool)), - clean(tool.error.orEmpty()), - output(tool), - tool.error.orEmpty(), - ) +/** Full shell markdown (command surface then output surface), used for copy and popup fallbacks. */ +private fun shellMarkdown(tool: Tool): String = + listOf(commandMarkdown(tool), outputMarkdown(tool)).filter { it.isNotBlank() }.joinToString("\n\n") - val body: String = listOf(command, output, error).filter { it.isNotBlank() }.joinToString("\n\n") +/** Plain, terminal-cleaned shell text used as the copy fallback before the body is built. */ +private fun shellBodyText(tool: Tool): String = + listOf(command(tool), clean(output(tool)), clean(tool.error.orEmpty())) + .filter { it.isNotBlank() } + .joinToString("\n\n") - val markdown: String = buildString { - section(KiloBundle.message("session.part.tool.shell.command"), command, "bash") - section(KiloBundle.message("session.part.tool.shell.output"), rawOutput, outputLang(rawOutput)) - section(KiloBundle.message("session.part.tool.shell.error"), rawError, "ansi-stderr") - } +private fun fenced(text: String, lang: String): String = buildString { + val fence = fence(text) + append(fence).append(lang).append('\n') + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) } private fun outputLang(text: String): String = if (MdTerminal.hasAnsi(text)) "ansi-stdout" else "shell-output" @@ -286,6 +374,16 @@ private fun popupMd(text: String): String = buildString { append(fence) } +/** + * Popup markdown: the formatted command surface followed by the output/error surface, matching the + * expanded body. Like the changes popup, the output is not line-capped here — the popup's own scroll + * pane bounds its height — so the collapsed hover preview shows the command together with its output. + */ +private fun popupShellMarkdown(tool: Tool, cmd: String): String = + listOf(popupMd(formatCommand(cmd)), outputMarkdown(tool)) + .filter { it.isNotBlank() } + .joinToString("\n\n") + /** * Inserts line breaks after shell separators (`&&`, `||`, `|`, `;`) that sit outside quotes, * so a long single-line command reads as one statement per line in the popup. Quote and escape @@ -323,15 +421,4 @@ private fun formatCommand(cmd: String): String { return out.toString() } -private fun StringBuilder.section(title: String, text: String, lang: String) { - if (text.isBlank()) return - if (isNotEmpty()) append("\n\n") - val fence = fence(text) - append("**").append(title).append("**\n\n") - append(fence).append(lang).append("\n") - append(text) - if (!text.endsWith('\n')) append('\n') - append(fence) -} - private fun clean(text: String): String = MdTerminal.strip(MdTerminal.reduce(text, keepSgr = false)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 89b76161bb6..7d5b91a370b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -4,10 +4,11 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.SessionCodeScroll import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis @@ -31,7 +32,7 @@ class TaskToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool), -) : SecondarySessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider { +) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider { override val contentId: String = tool.id @@ -42,7 +43,6 @@ class TaskToolView( private var collapsed = false init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() @@ -105,7 +105,7 @@ class TaskToolView( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } @@ -221,7 +221,7 @@ class TaskToolView( private var item = tool val icon = JBLabel() val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val sub = JBLabel().apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val panel = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false add(icon, BorderLayout.WEST) @@ -265,7 +265,7 @@ private class TaskBody(glyph: JBLabel) { val panel = object : JPanel(BorderLayout()) { override fun updateUI() { super.updateUI() - background = SessionUiStyle.View.Surface.bgColor() + background = SessionUiStyle.Colors.codeBlockBackground() border = taskBodyBorder(glyph) } }.apply { @@ -274,7 +274,7 @@ private class TaskBody(glyph: JBLabel) { val scroll = TaskBodyScroll(this) } -private class TaskBodyScroll(val body: TaskBody) : JBScrollPane(body.panel) { +private class TaskBodyScroll(val body: TaskBody) : SessionCodeScroll(body.panel) { val rows: Stack get() = body.rows val panel: JPanel get() = body.panel @@ -286,8 +286,7 @@ private class TaskBodyScroll(val body: TaskBody) : JBScrollPane(body.panel) { override fun updateUI() { super.updateUI() border = JBUI.Borders.empty() - background = SessionUiStyle.View.Surface.bgColor() - viewport?.background = SessionUiStyle.View.Surface.bgColor() + viewport?.background = SessionUiStyle.Colors.codeBlockBackground() } } @@ -313,7 +312,7 @@ private class TaskRows : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Scrollable private fun rowTitleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { UiStyle.Colors.errorLabelForeground() } else { - UiStyle.Colors.weak() + SessionUiStyle.Text.Secondary.foreground() } private fun taskBodyBorder(glyph: JBLabel) = run { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt index 24ec73db2ad..7dad1eea5e2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.diff.installDiffGutter import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions import ai.kilocode.client.ui.md.MdView @@ -88,8 +89,10 @@ class ToolMarkdownBody( md.applyStyle(style) md.font = font(style) md.foreground = style.editorForeground - md.background = style.editorBackground - md.preBg = style.editorBackground + // The body itself stays transparent (session backdrop); only the code/output panes are the + // raised editor-background surface, so the fill tracks the content and its insets exactly. + md.opaque = false + md.preBg = SessionUiStyle.Colors.codeBlockBackground() md.codeFont = style.editorFamily md.component.border = JBUI.Borders.empty() chrome(md) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 3eabce5da94..9706e8ef69a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.session.ui.SessionCodeScroll import ai.kilocode.client.session.ui.fileLinkHtml import ai.kilocode.client.session.ui.fileLinkText import ai.kilocode.client.session.ui.selection.SessionSelection @@ -125,7 +126,7 @@ class FileLinkLabel( init { isVisible = false isFocusable = false - foreground = UiStyle.Colors.fg() + foreground = SessionUiStyle.Colors.foreground() cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) setRequestFocusEnabled(false) addMouseListener(object : MouseAdapter() { @@ -191,7 +192,7 @@ class ToolBody private constructor( var foreground: Color @RequiresEdt - get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg() + get() = area?.foreground ?: ed?.foreground ?: SessionUiStyle.Colors.foreground() @RequiresEdt set(value) { area?.foreground = value @@ -306,29 +307,24 @@ class ToolBody private constructor( caret.isSelectionVisible = true lineWrap = wrap wrapStyleWord = wrap - foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - background = SessionUiStyle.View.Surface.bgColor() + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else SessionUiStyle.Colors.foreground() + background = SessionUiStyle.Colors.codeBlockBackground() border = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) } - private fun pane(view: JComponent, scrolls: Boolean) = JBScrollPane(view).apply { - border = JBUI.Borders.customLine( - SessionUiStyle.View.Outline.color(), - SessionUiStyle.View.Outline.width(), - 0, - 0, - 0, - ) + private fun pane(view: JComponent, scrolls: Boolean) = SessionCodeScroll(view).apply { + // No separator line: the card's transparent header-to-content gap already sets the body + // apart, so the body is a raised, rounded code surface matching the header block arc. + border = JBUI.Borders.empty() viewportBorder = JBUI.Borders.empty( JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ).takeIf { scrolls } - isOpaque = true - background = SessionUiStyle.View.Surface.bgColor() - viewport.background = SessionUiStyle.View.Surface.bgColor() + viewport.isOpaque = true + viewport.background = SessionUiStyle.Colors.codeBlockBackground() horizontalScrollBarPolicy = if (scrolls) { ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED } else { @@ -370,9 +366,9 @@ private class ToolField(value: String, private var style: SessionEditorStyle, pr ed.setBorder(JBUI.Borders.empty()) ed.scrollPane.border = JBUI.Borders.empty() ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = SessionUiStyle.View.Surface.bgColor() - ed.scrollPane.background = SessionUiStyle.View.Surface.bgColor() - ed.scrollPane.viewport.background = SessionUiStyle.View.Surface.bgColor() + ed.backgroundColor = SessionUiStyle.Colors.codeBlockBackground() + ed.scrollPane.background = SessionUiStyle.Colors.codeBlockBackground() + ed.scrollPane.viewport.background = SessionUiStyle.Colors.codeBlockBackground() ed.settings.isUseSoftWraps = false ed.settings.isAdditionalPageAtBottom = false ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER @@ -404,14 +400,14 @@ internal fun toolParts( ): ToolParts { val glyph = JBLabel() val title = clip(JBLabel()) - val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } + val sub = clip(JBLabel()).apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val link = clip(FileLinkLabel(openFile)) val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply { minimumSize = Dimension(0, minimumSize.height) next(sub) next(link) } - val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } + val state = clip(JBLabel()).apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val header = PartHeader().apply { leading(glyph) left(title) @@ -426,10 +422,10 @@ internal fun toolParts( internal fun searchParts(count: Int): ToolParts { val glyph = JBLabel() val title = clip(JBLabel()) - val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } + val sub = clip(JBLabel()).apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val targets = List(count) { clip(JBLabel()).apply { - foreground = UiStyle.Colors.fg() + foreground = SessionUiStyle.Colors.foreground() } } val link = clip(FileLinkLabel()) @@ -438,7 +434,7 @@ internal fun searchParts(count: Int): ToolParts { next(sub) next(link) } - val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } + val state = clip(JBLabel()).apply { foreground = SessionUiStyle.Text.Secondary.foreground() } val target = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply { minimumSize = Dimension(0, minimumSize.height) targets.forEach { next(it) } @@ -568,7 +564,7 @@ internal fun color(tool: Tool) = when (tool.state) { internal fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { UiStyle.Colors.errorLabelForeground() } else { - UiStyle.Colors.fg() + SessionUiStyle.Colors.foreground() } internal fun stateText(tool: Tool) = when (tool.state) { @@ -651,6 +647,19 @@ internal fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview internal fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) +/** Fenced markdown of a tool's body text for the collapsed hover popup; empty when nothing to show. */ +internal fun toolBodyMarkdown(tool: Tool): String { + val text = body(tool) + if (text.isBlank()) return "" + val fence = fence(text) + return buildString { + append(fence).append('\n') + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) + } +} + private fun shellPreview(tool: Tool): String { val cmd = command(tool) val out = output(tool) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index 15ba583933b..ec225239a30 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -3,10 +3,11 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider @@ -21,7 +22,7 @@ class ToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), -) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }), UiDataProvider { +) : AbstractSessionPartView(parts.header, { parts.scroll(tool) }), UiDataProvider { override val contentId: String = tool.id @@ -31,7 +32,6 @@ class ToolView( private var disposed = false init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot) applyStyle(style) sync() } @@ -55,7 +55,7 @@ class ToolView( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + expandedGap() + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } @@ -71,6 +71,14 @@ class ToolView( if (changed) refresh() } + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + val md = toolBodyMarkdown(item) + return popup("tool", item.name, md.isNotBlank()) { + markdownPopupBody(style, md, options = POPUP_OPTS, foreground = bodyColor()) + } + } + @RequiresEdt fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) .filter { it.isNotBlank() } @@ -181,7 +189,7 @@ class ToolView( return body.applyStyle(style) } - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else SessionUiStyle.Colors.foreground() private fun bodyMaxHeight(): Int { val body = parts.content ?: return 0 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index c5131722f59..a1a90e498e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -180,11 +180,7 @@ object UiStyle { /** Uses the editor background so chat cards feel native beside editor content. */ fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } - /** - * Background for rendered code fragments (markdown code blocks). Uses the editor's doc - * code-block attribute background and falls back to the editor background when the theme - * leaves it unset. - */ + /** Background for code fragments when a caller explicitly wants the editor scheme's doc-code style. */ fun codeBlockBackground(scheme: EditorColorsScheme): Color = scheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK)?.backgroundColor ?: scheme.defaultBackground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index d95db32ee09..779fb212354 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -77,8 +77,8 @@ internal object MdCommon { rules.append("a.kilo-file-ref, code a.kilo-file-ref { color: ${hex(SessionUiStyle.View.Markdown.string())}; font-family: '${css(opts.codeFont)}', monospace; text-decoration: underline } ") rules.append("ul, ol { color: ${hex(opts.listMarkerFg)} } ") rules.append("li { color: ${hex(opts.foreground)} } ") - rules.append("tt, code, samp, pre, pre code { font-family: '${css(opts.codeFont)}', monospace } ") - rules.append("pre { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)}; border-color: ${hex(opts.codeBorder)} } ") + rules.append("tt, code, samp, pre, pre code { font-family: '${css(opts.codeFont)}', monospace; border-width: 0 } ") + rules.append("pre { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)}; border-color: ${hex(opts.codeBorder)}; border-width: 0 } ") rules.append("pre code { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)} } ") rules.append("blockquote { background: ${hex(opts.quoteBg)}; border-left-color: ${hex(opts.quoteBorder)}; color: ${hex(opts.quoteFg)} } ") rules.append("blockquote p { color: ${hex(opts.quoteFg)} } ") @@ -94,7 +94,6 @@ internal object MdCommon { ?: fg(style, DefaultLanguageHighlighterColors.DOC_COMMENT) ?: UIUtil.getContextHelpForeground() val border = color(style, EditorColors.PREVIEW_BORDER_COLOR) ?: UiStyle.Colors.contentBorder() - val blockBg = UiStyle.Colors.codeBlockBackground(style.editorScheme) return MdStyle( font = style.transcriptFont, foreground = style.editorForeground, @@ -103,7 +102,7 @@ internal object MdCommon { codeBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) ?: bg(style, DefaultLanguageHighlighterColors.STRING) ?: style.editorBackground, - preBg = blockBg, + preBg = style.editorBackground, preFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorForeground, codeFont = style.editorFamily, quoteBorder = border, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt index 15dcf0dd677..adcb824f940 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionUiStyle import javax.swing.ScrollPaneConstants object MdViewFactory { @@ -27,10 +28,17 @@ object MdViewFactory { } data class MdCodeBlockOptions( - val border: MdCodeBlockBorder = MdCodeBlockBorder.All, + val border: MdCodeBlockBorder = MdCodeBlockBorder.None, val maxLines: Int? = null, val verticalPolicy: Int = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, val editorOnly: Boolean = false, + val horizontalPadding: Int = SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + /** + * Float the horizontal scrollbar over the content instead of reserving a band beneath it. Keeps + * the block's bottom inset equal to the other views when the content fits (no empty scrollbar + * band), while the scrollbar still appears on demand for wide content such as diffs. + */ + val overlapScrollbar: Boolean = false, ) enum class MdCodeBlockBorder { All, Horizontal, Bottom, None } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index 82310c6cd7e..f4fbe03a7af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -1,8 +1,9 @@ package ai.kilocode.client.ui.md.hybrid -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.SessionSurface import ai.kilocode.client.session.ui.selection.SessionCopyTarget +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockFactory @@ -32,7 +33,10 @@ import java.awt.Color import java.awt.Component import java.awt.Dimension import java.awt.Font +import java.awt.Graphics +import java.awt.Graphics2D import java.awt.Point +import java.awt.RenderingHints import java.awt.event.HierarchyEvent import java.awt.event.MouseEvent import javax.swing.Box @@ -329,7 +333,7 @@ internal open class MdViewHybrid( return object : JBHtmlPane( JBHtmlPaneStyleConfiguration { enableInlineCodeBackground = false - enableCodeBlocksBackground = true + enableCodeBlocksBackground = false }, JBHtmlPaneConfiguration { customStyleSheetProvider { sheet() } @@ -508,18 +512,19 @@ internal open class MdViewHybrid( } viewportBorder = JBUI.Borders.empty( SessionUiStyle.View.Code.topPadding(), - SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + code.opts.horizontalPadding, SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, - SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + code.opts.horizontalPadding, ) - isOpaque = true + // CodePane paints its own (optionally rounded) fill, so the pane stays non-opaque; the + // viewport still fills the inner rectangle with the surface color. background = opts.preBg viewport.isOpaque = true viewport.background = opts.preBg horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED verticalScrollBarPolicy = code.opts.verticalPolicy isWheelScrollingEnabled = true - setOverlappingScrollBar(false) + setOverlappingScrollBar(code.opts.overlapScrollbar) horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) horizontalScrollBar.isOpaque = true if (code.opts.verticalPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) { @@ -571,8 +576,10 @@ internal open class MdViewHybrid( val pad = pane.viewportBorder.getBorderInsets(pane) val text = fieldText(component) val content = codeHeight(component, text, code.opts.maxLines) - val height = content + pane.insets.top + pane.insets.bottom + - pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height + // An overlapping scrollbar floats over the content, so it reserves no bottom band; only add + // the scrollbar height when it takes its own row beneath the content. + val scrollbar = if (code.opts.overlapScrollbar) 0 else pane.horizontalScrollBar.preferredSize.height + val height = content + pane.insets.top + pane.insets.bottom + pad.top + pad.bottom + scrollbar pane.preferredSize = Dimension(0, height) pane.minimumSize = Dimension(0, height) pane.maximumSize = Dimension(Int.MAX_VALUE, height) @@ -677,6 +684,29 @@ internal open class MdViewHybrid( } private open inner class CodePane(component: JComponent) : JBScrollPane(component) { + // Non-opaque so the surface can round its corners over the backdrop; the fill is painted + // below. Bodies that draw their own edge separators (a non-None border, e.g. tool diffs) + // keep square corners. + override fun isOpaque(): Boolean = false + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = background + val arc = if (code.opts.border == MdCodeBlockBorder.None) JBUI.scale(SessionUiStyle.View.BLOCK_ARC) else 0 + if (arc > 0) g2.fillRoundRect(0, 0, width, height, arc, arc) else g2.fillRect(0, 0, width, height) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + override fun paintChildren(g: Graphics) { + if (code.opts.border != MdCodeBlockBorder.None) return super.paintChildren(g) + SessionSurface.clipped(g, width, height) { super.paintChildren(it) } + } + override fun doLayout() { super.doLayout() if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 9a3a19bc4d9..b12492b70a6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -56,18 +56,23 @@ class SessionUiLayoutTest : SessionUiTestBase() { } fun `test session surfaces use session background from initial render`() { - val bg = SessionEditorStyle.current().editorBackground + val bg = SessionUiStyle.Colors.sessionBackground() val root = find(ui) val pane = scrollComponent() as JBScrollPane - assertEquals(bg, ui.background) - assertEquals(bg, root.content.background) - assertEquals(bg, pane.background) - assertEquals(bg, pane.viewport.background) + // One opaque backdrop, self-rendered from the theme at first paint (no applyStyle push). + assertTrue(root.isOpaque) + assertEquals(bg, root.background) + // Intermediate containers stay transparent over that single backdrop. + assertFalse(root.content.isOpaque) + assertFalse(pane.isOpaque) + assertFalse(pane.viewport.isOpaque) showMessages() - assertEquals(bg, find(ui).background) + val messages = find(ui) + assertFalse(messages.isOpaque) + assertEquals(bg, root.background) } fun `test prompt is docked and connection is overlaid`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt index 045b139dc61..7a441365af1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ModifiedFilesViewTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.rpc.dto.DiffFileDto @@ -9,10 +10,13 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.HyperlinkLabel import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container +import java.awt.image.BufferedImage import javax.swing.AbstractButton +import javax.swing.JComponent class ModifiedFilesViewTest : BasePlatformTestCase() { private lateinit var view: ModifiedFilesView @@ -66,6 +70,23 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { val links = components(view).filterIsInstance().filter { it.text?.contains("") == true } assertTrue(links.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" }) assertTrue(links.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" }) + assertFileHeadersHaveNoSeparators(view) + diffScrolls(view).forEach(::assertFullWidthRoundedDiff) + } + + fun `test popup modified file headers have no separators`() { + view.setDiffs(listOf( + file("src/A.kt", 2, 0, ADD), + file("pkg/B.kt", 1, 1, UPDATE), + )) + val body = view.headerPopup()!!.build() + + try { + assertFileHeadersHaveNoSeparators(body.component) + diffScrolls(body.component).forEach(::assertFullWidthRoundedDiff) + } finally { + Disposer.dispose(body.disposable) + } } fun `test popup is available only when collapsed`() { @@ -142,6 +163,41 @@ class ModifiedFilesViewTest : BasePlatformTestCase() { return out } + private fun diffScrolls(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) diffScrolls(child) else emptyList() + if (child is JBScrollPane && child.viewport.view is EditorTextField) nested + child else nested + } + + private fun assertFullWidthRoundedDiff(pane: JBScrollPane) { + val border = pane.border.getBorderInsets(pane) + val viewport = pane.viewportBorder.getBorderInsets(pane) + assertEquals(0, border.top) + assertEquals(0, border.left) + assertEquals(0, border.bottom) + assertEquals(0, border.right) + assertEquals(0, viewport.left) + assertEquals(0, viewport.right) + assertFalse("diff pane paints its own rounded background", pane.isOpaque) + + pane.setSize(40, 40) + val image = BufferedImage(40, 40, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + pane.paint(graphics) + graphics.dispose() + assertEquals("rounded corner lets the backdrop show", 0, image.getRGB(0, 0) ushr 24) + assertEquals(SessionUiStyle.Colors.codeBlockBackground().rgb, image.getRGB(20, 20)) + } + + private fun assertFileHeadersHaveNoSeparators(root: Component) { + val links = components(root).filterIsInstance().filter { it.text?.contains("") == true } + assertTrue("expected file link headers", links.isNotEmpty()) + links.forEach { link -> + val header = link.parent?.parent as? JComponent + assertNotNull("file link should live inside a patch header panel", header) + assertNull("patch file header should not draw a separator", header!!.border) + } + } + private fun openDiffButton(): AbstractButton = view.copyToolbar as AbstractButton private fun file(path: String, additions: Int, deletions: Int, patch: String) = DiffFileDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 2c112f306b6..452f1bae015 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource @@ -45,6 +46,11 @@ class ProgressPanelTest : BasePlatformTestCase() { assertFalse(panel.isVisible) } + fun `test panel paints the session background`() { + assertTrue(panel.isOpaque) + assertEquals(SessionUiStyle.Colors.sessionBackground().rgb, panel.background.rgb) + } + fun `test panel shows on Busy with text`() { model.setState(SessionState.Busy("Thinking\u2026")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionContentPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionContentPanelTest.kt new file mode 100644 index 00000000000..208ddff827e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionContentPanelTest.kt @@ -0,0 +1,74 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import java.awt.Dimension +import java.awt.image.BufferedImage + +@Suppress("UnstableApiUsage") +class SessionContentPanelTest : BasePlatformTestCase() { + + fun `test content panel and footer are transparent`() { + val panel = SessionContentPanel() + + assertFalse("the content column paints nothing itself", panel.isOpaque) + assertFalse("footer region is empty until used", panel.hasFooter()) + } + + fun `test footer region appears only once a component is added`() { + val panel = SessionContentPanel() + + assertFalse(panel.hasFooter()) + panel.footer(JBLabel("auto-approved")) + + assertTrue(panel.hasFooter()) + } + + fun `test content pieces stack separated by the standard gap`() { + val panel = SessionContentPanel() + val first = sized() + val second = sized() + panel.content(first).content(second) + panel.setSize(120, panel.preferredSize.height) + layout(panel) + + // The two content surfaces are laid out top-to-bottom, split by exactly the standard gap. + assertEquals(first.height + SessionUiStyle.View.contentGap(), y(second) - y(first)) + } + + fun `test surface paints a rounded code-block background`() { + val surface = SessionSurfacePanel() + surface.setSize(40, 40) + val image = BufferedImage(40, 40, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + surface.paint(graphics) + graphics.dispose() + + assertEquals(SessionUiStyle.Colors.codeBlockBackground().rgb, surface.background.rgb) + assertEquals("center is the raised code surface", SessionUiStyle.Colors.codeBlockBackground().rgb, image.getRGB(20, 20)) + assertEquals("corner is rounded away, letting the backdrop show", 0, image.getRGB(0, 0) ushr 24) + } + + private fun sized() = JBLabel("x").apply { + val size = Dimension(80, 20) + preferredSize = size + minimumSize = size + maximumSize = size + } + + private fun y(component: java.awt.Component): Int { + var node: java.awt.Component? = component + var top = 0 + while (node != null && node !is SessionContentPanel) { + top += node.y + node = node.parent + } + return top + } + + private fun layout(root: java.awt.Container) { + root.doLayout() + root.components.filterIsInstance().forEach(::layout) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index d6b8082ca21..6448c818811 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.ide.ui.UISettings import com.intellij.ide.ui.UISettingsUtils @@ -97,9 +98,9 @@ class SessionEditorStyleTest : BasePlatformTestCase() { assertEquals(UiStyle.Fonts.header(), style.headerFont) } - fun `test hintFont equals UiStyle Fonts hint`() { + fun `test secondary text font equals UiStyle Fonts regular`() { val style = SessionEditorStyle.create(family = "Courier New", size = 22) - assertEquals(UiStyle.Fonts.hint(), style.hintFont) + assertEquals(UiStyle.Fonts.regular(), SessionUiStyle.Text.Secondary.font(style)) } fun `test regularFont equals UiStyle Fonts regular`() { @@ -122,7 +123,7 @@ class SessionEditorStyleTest : BasePlatformTestCase() { val style = SessionEditorStyle.create(family = "Courier New", size = 22) assertFalse("headerFont should not use editor font family", style.headerFont.name == "Courier New") - assertFalse("hintFont should not use editor font family", style.hintFont.name == "Courier New") + assertFalse("secondary text font should not use editor font family", SessionUiStyle.Text.Secondary.font(style).name == "Courier New") assertFalse("regularFont should not use editor font family", style.regularFont.name == "Courier New") assertFalse("boldFont should not use editor font family", style.boldFont.name == "Courier New") assertFalse("smallFont should not use editor font family", style.smallFont.name == "Courier New") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 8f369199893..98fec843ec6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -67,7 +67,6 @@ import javax.swing.JPanel import javax.swing.RepaintManager import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities -import javax.swing.border.Border private val PATCH = """ diff --git a/src/A.kt b/src/A.kt @@ -361,10 +360,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { } fun `test turn view hides when all messages are reverted`() { - model.upsertMessage(msg("u1", "user")) - model.upsertMessage(msg("a1", "assistant")) - model.upsertMessage(msg("u2", "user")) - model.upsertMessage(msg("a2", "assistant")) + // Messages carry content so their visibility reflects revert state rather than emptiness. + model.loadHistory(listOf( + MessageWithPartsDto(msg("u1", "user"), listOf(part("u1p", "u1", "text", "hi"))), + MessageWithPartsDto(msg("a1", "assistant"), listOf(part("a1p", "a1", "text", "ok"))), + MessageWithPartsDto(msg("u2", "user"), listOf(part("u2p", "u2", "text", "more"))), + MessageWithPartsDto(msg("a2", "assistant"), listOf(part("a2p", "a2", "text", "done"))), + )) model.setRevert(SessionRevertDto("u2")) @@ -376,8 +378,10 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { } fun `test turn view shows again when revert clears`() { - model.upsertMessage(msg("u1", "user")) - model.upsertMessage(msg("u2", "user")) + model.loadHistory(listOf( + MessageWithPartsDto(msg("u1", "user"), listOf(part("u1p", "u1", "text", "hi"))), + MessageWithPartsDto(msg("u2", "user"), listOf(part("u2p", "u2", "text", "more"))), + )) model.setRevert(SessionRevertDto("u2")) model.setRevert(null) @@ -386,6 +390,19 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(panel.findMessage("u2")!!.isVisible) } + fun `test empty user anchor is hidden while its turn and assistant content stay visible`() { + model.loadHistory(listOf( + MessageWithPartsDto(msg("u1", "user"), emptyList()), + MessageWithPartsDto(msg("a1", "assistant"), listOf(part("a1p", "a1", "text", "hi"))), + )) + + // The bare user anchor renders nothing, so it is hidden... + assertFalse(panel.findMessage("u1")!!.isVisible) + // ...but the turn and its assistant content remain visible. + assertTrue(panel.findMessage("a1")!!.isVisible) + assertTrue(panel.findTurn("u1")!!.isVisible) + } + // ------ TurnRemoved ------ fun `test removing only message removes the turn`() { @@ -819,9 +836,12 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { message.paint(graphics) graphics.dispose() - val line = SessionUiStyle.View.Outline.color().rgb - assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb) - assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y + box.height - 1), true).rgb) + // The borderless bubble fills its surface; probing the box edges and center verifies it + // paints the fill at the wrapped coordinates. + val fill = SessionUiStyle.View.Prompt.bgColor(SessionEditorStyle.current()).rgb + assertEquals(fill, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb) + assertEquals(fill, Color(image.getRGB(point.x + box.width / 2, point.y + box.height - 1), true).rgb) + assertEquals(fill, Color(image.getRGB(point.x + box.width / 2, point.y + box.height / 2), true).rgb) } fun `test created ContentDelta is not double applied`() { @@ -1479,22 +1499,17 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { ) val first = panel.findMessage("a1")!!.part("tp1") as QuestionResultView val second = panel.findMessage("a1")!!.part("tp2") as QuestionResultView - val firstRoot = root(first) - val secondRoot = root(second) first.toggle() second.toggle() enter(header(first)) assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(first).background.rgb) - assertLine(firstRoot.border) enter(header(second)) assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header(first).background.rgb) assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(second).background.rgb) - assertLine(firstRoot.border) - assertLine(secondRoot.border) } // ------ helpers ------ @@ -1585,9 +1600,8 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"), ) - private fun root(view: QuestionResultView) = view.components[0] as JPanel - - private fun header(view: QuestionResultView) = root(view).components[0] as JPanel + // The hover surface is the base header row (child 0) of the card. + private fun header(view: QuestionResultView) = view.components[0] as JPanel private fun enter(component: Component) { component.dispatchEvent(MouseEvent( @@ -1602,19 +1616,6 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { )) } - private fun assertLine(border: Border) { - val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) - val item = JPanel() - val graphics = image.createGraphics() - border.paintBorder(item, graphics, 0, 0, image.width, image.height) - graphics.dispose() - val rgb = SessionUiStyle.View.Outline.brightColor().rgb - assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) - assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) - assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) - assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) - } - private fun count(root: Component): Int { if (root !is Container) return 1 return 1 + root.components.sumOf(::count) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt index 74af7d69600..85455ca6648 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.icons.AllIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -31,11 +31,13 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertFalse(root.blocker.isVisible) } - fun `test blocker is opaque and uses panel background`() { + fun `test blocker is opaque and uses session background`() { val root = SessionRootPanel() + assertTrue(root.isOpaque) + assertEquals(SessionUiStyle.Colors.sessionBackground(), root.background) assertTrue(root.blocker.isOpaque) - assertEquals(UiStyle.Colors.bg(), root.blocker.background) + assertEquals(SessionUiStyle.Colors.sessionBackground(), root.blocker.background) } fun `test root layout fills all immediate children`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index 10bab7b4b3e..e8d4d59ea74 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -24,6 +24,7 @@ import com.intellij.ui.components.JBScrollPane import java.awt.Component import java.awt.Container import java.awt.Cursor +import java.awt.Dimension import java.awt.datatransfer.DataFlavor import java.awt.event.MouseEvent import java.awt.Point @@ -235,7 +236,7 @@ class SessionSelectionCopyTest : SessionUiTestBase() { val root = ShowingPanel() val parent = Disposer.newDisposable("overlay-test") val target = TargetPanel("alpha") - val overlay = SessionHoverCopyOverlay(root, parent) + val overlay = SessionHoverCopyOverlay(root, root, parent) root.setBounds(0, 0, 100, 100) target.setBounds(10, 10, 80, 80) root.add(target) @@ -247,6 +248,55 @@ class SessionSelectionCopyTest : SessionUiTestBase() { assertFalse(overlay.isVisible) } + fun `test hover copy overlay bounds stay inside scroll area`() { + val root = ShowingPanel() + val area = ShowingPanel() + val parent = Disposer.newDisposable("overlay-test") + val target = TargetPanel("alpha") + val overlay = SessionHoverCopyOverlay(root, area, parent) + root.setBounds(0, 0, 200, 200) + area.setBounds(0, 0, 200, 100) + target.setBounds(170, 90, 20, 20) + area.add(target) + root.add(area) + root.add(overlay) + + try { + show(overlay, target) + val bounds = overlay.bounds(root, overlay.components.single() as JComponent) + + assertTrue("copy overlay should stay above the prompt/status area", bounds.y + bounds.height <= area.y + area.height) + } finally { + Disposer.dispose(parent) + } + } + + fun `test hover toolbar centers on a zero-height inline anchor but bottom-aligns a footer anchor`() { + val root = ShowingPanel().also { it.setBounds(0, 0, 300, 300) } + val area = ShowingPanel().also { it.setBounds(0, 0, 300, 300) } + val toolbar = JPanel().also { it.preferredSize = Dimension(24, 24) } + root.add(area) + + val inline = InlineTarget(fixed(24, 0), toolbar) + val footer = InlineTarget(fixed(24, 40), toolbar) + listOf(inline.copyAnchor, footer.copyAnchor).forEach { it.setBounds(100, 50, 24, 40); area.add(it) } + val parent = Disposer.newDisposable("overlay-center") + val overlay = SessionHoverCopyOverlay(root, area, parent) + root.add(overlay) + + try { + show(overlay, inline) + // Zero-height anchor => inline header control: centered in the 40px row. + assertEquals(50 + (40 - 24) / 2, overlay.bounds(root, toolbar).y) + + show(overlay, footer) + // Real-height anchor => footer row: button hugs the bottom. + assertEquals(50 + 40 - 24, overlay.bounds(root, toolbar).y) + } finally { + Disposer.dispose(parent) + } + } + fun `test session context menu can reinstall after parent disposal`() { val root = JPanel(null) val one = Disposer.newDisposable("context-one") @@ -428,7 +478,20 @@ class SessionSelectionCopyTest : SessionUiTestBase() { override fun copyText() = value } - private class ShowingPanel : JPanel(null) { + private class InlineTarget( + private val anchor: JComponent, + private val toolbar: JComponent, + ) : JPanel(), SessionCopyTarget { + override val copyAnchor: JComponent get() = anchor + override val copyToolbar: JComponent get() = toolbar + override fun copyText(): String? = null + } + + private fun fixed(width: Int, height: Int): JComponent = object : ShowingPanel() { + override fun getPreferredSize() = Dimension(width, height) + } + + private open class ShowingPanel : JPanel(null) { override fun isShowing() = true } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index f0f48196007..f68b703234f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -76,7 +76,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals("cache read 75", panel.cacheReadText()) assertEquals("1/2 todos complete", panel.todoText()) assertTrue(panel.todoVisible()) - assertEquals(style.editorBackground, panel.background) + assertEquals(SessionUiStyle.Colors.sessionBackground(), panel.background) assertEquals( List(panel.foregrounds().size) { style.editorForeground }, panel.foregrounds(), @@ -288,7 +288,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { panel.applyStyle(style) - assertEquals(style.editorBackground, panel.background) + assertEquals(SessionUiStyle.Colors.sessionBackground(), panel.background) assertEquals( List(panel.foregrounds().size) { style.editorForeground }, panel.foregrounds(), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyleTest.kt new file mode 100644 index 00000000000..43a1e7520a7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyleTest.kt @@ -0,0 +1,72 @@ +package ai.kilocode.client.session.ui.style + +import ai.kilocode.client.ui.UiStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import javax.swing.UIManager + +@Suppress("UnstableApiUsage") +class SessionUiStyleTest : BasePlatformTestCase() { + + fun `test session background shifts when panel matches the raised editor surface`() { + val raised = SessionUiStyle.Colors.codeBlockBackground() + val previous = UIManager.getColor("Panel.background") + try { + // Force the Islands case: the panel/backdrop is exactly the raised editor surface. + UIManager.put("Panel.background", Color(raised.rgb)) + val bg = SessionUiStyle.Colors.sessionBackground() + + // When the panel equals the raised surface the backdrop must shift to a distinct color: + // lighter in dark themes, darker in light themes. + assertTrue("Backdrop must not equal the raised surface", raised.rgb != bg.rgb) + val dark = raised.red * 0.299 + raised.green * 0.587 + raised.blue * 0.114 < 128 + if (dark) { + assertTrue("Dark themes shift the backdrop lighter", brighter(bg, raised)) + } else { + assertTrue("Light themes shift the backdrop darker", darker(bg, raised)) + } + } finally { + UIManager.put("Panel.background", previous) + } + } + + fun `test session background follows the panel when it differs from the raised surface`() { + val previous = UIManager.getColor("Panel.background") + try { + val panel = UiStyle.Colors.contrast(SessionUiStyle.Colors.codeBlockBackground(), 40) + UIManager.put("Panel.background", panel) + + assertEquals(panel.rgb, SessionUiStyle.Colors.sessionBackground().rgb) + } finally { + UIManager.put("Panel.background", previous) + } + } + + fun `test secondary text blends foreground toward session background and uses regular font`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 22) + val bg = UIManager.getColor("Kilo.Session.background") + val fg = UIManager.getColor("Kilo.Session.foreground") + try { + UIManager.put("Kilo.Session.background", Color.BLACK) + UIManager.put("Kilo.Session.foreground", Color.WHITE) + assertTrue(luminance(SessionUiStyle.Text.Secondary.foreground()) < luminance(SessionUiStyle.Colors.foreground())) + + UIManager.put("Kilo.Session.background", Color.WHITE) + UIManager.put("Kilo.Session.foreground", Color.BLACK) + assertTrue(luminance(SessionUiStyle.Text.Secondary.foreground()) > luminance(SessionUiStyle.Colors.foreground())) + } finally { + UIManager.put("Kilo.Session.background", bg) + UIManager.put("Kilo.Session.foreground", fg) + } + + assertEquals(UiStyle.Fonts.regular(), SessionUiStyle.Text.Secondary.font(style)) + } + + private fun brighter(shifted: Color, base: Color) = + shifted.red >= base.red && shifted.green >= base.green && shifted.blue >= base.blue + + private fun darker(shifted: Color, base: Color) = + shifted.red <= base.red && shifted.green <= base.green && shifted.blue <= base.blue + + private fun luminance(color: Color) = color.red * 0.299 + color.green * 0.587 + color.blue * 0.114 +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt index 45adacf4395..6527f4df3cd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt @@ -4,7 +4,6 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.EditToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.ToolView @@ -17,6 +16,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.HyperlinkLabel import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import kotlinx.serialization.json.addJsonObject @@ -26,6 +26,7 @@ import kotlinx.serialization.json.put import java.awt.Component import java.awt.Container import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.AbstractButton @Suppress("UnstableApiUsage") @@ -42,9 +43,7 @@ class EditToolViewTest : BasePlatformTestCase() { fun `test edit tool shows Edit title and clickable file link`() { val opened = mutableListOf() val view = track(EditToolView(tool(), openFile = { href, _ -> opened.add(href) })) - val base: Any = view - assertTrue(base is SecondarySessionPartView) assertTrue(view.labelText().contains("Edit")) assertTrue(view.linkVisible()) assertEquals("App.kt", view.linkLabel()) @@ -129,6 +128,7 @@ class EditToolViewTest : BasePlatformTestCase() { // The per-file header renders one changes badge per file (plus the aggregate header badge). assertEquals(3, badges(view).size) + diffScrolls(view).forEach(::assertFullWidthRoundedDiff) click(fileLinks.first { it.text!!.contains("A.kt") }, 1) assertEquals(listOf("src/A.kt"), opened) @@ -214,6 +214,7 @@ class EditToolViewTest : BasePlatformTestCase() { assertTrue(view.codeEditors().single().text.contains("new1")) assertFalse(view.codeEditors().single().text.contains("+new1")) assertFalse(view.codeEditors().single().text.contains("-old")) + assertFullWidthRoundedDiff(diffScrolls(view).single()) } fun `test edit body strips patch metadata headers`() { @@ -515,6 +516,31 @@ class EditToolViewTest : BasePlatformTestCase() { if (child is EditorTextField) nested + child else nested } + private fun diffScrolls(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) diffScrolls(child) else emptyList() + if (child is JBScrollPane && child.viewport.view is EditorTextField) nested + child else nested + } + + private fun assertFullWidthRoundedDiff(pane: JBScrollPane) { + val border = pane.border.getBorderInsets(pane) + val viewport = pane.viewportBorder.getBorderInsets(pane) + assertEquals(0, border.top) + assertEquals(0, border.left) + assertEquals(0, border.bottom) + assertEquals(0, border.right) + assertEquals(0, viewport.left) + assertEquals(0, viewport.right) + assertFalse("diff pane paints its own rounded background", pane.isOpaque) + + pane.setSize(40, 40) + val image = BufferedImage(40, 40, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + pane.paint(graphics) + graphics.dispose() + assertEquals("rounded corner lets the backdrop show", 0, image.getRGB(0, 0) ushr 24) + assertEquals(SessionUiStyle.Colors.codeBlockBackground().rgb, image.getRGB(20, 20)) + } + // Patches whose line count clears SessionUiStyle.View.Tool.DIFF_MAX_LINES so the body overflows. private fun bigPatch(lines: Int): String = buildString { append("--- src/App.kt\n") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index a9c8247e071..485ba263d95 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -3,13 +3,16 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -29,9 +32,7 @@ class GlobToolViewTest : BasePlatformTestCase() { val view = GlobToolView(tool().also { it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") }) - val base: Any = view - assertTrue(base is SecondarySessionPartView) assertTrue(view.labelText().contains("Glob")) assertEquals(listOf("/repo/src", "pattern=**/*.kt"), view.targetTexts()) assertTrue(view.targetVisible(1)) @@ -155,6 +156,37 @@ class GlobToolViewTest : BasePlatformTestCase() { assertFalse(ViewFactory.shouldReplace(GlobToolView(glob), glob)) } + fun `test glob header popup previews matches when collapsed`() { + val view = track(GlobToolView(tool().also { + it.input = mapOf("pattern" to "**/*.kt") + it.output = "src/A.kt\nsrc/B.kt" + })) + val body = view.headerPopup()!!.build() + try { + val editors = popupEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("src/A.kt\nsrc/B.kt"), editors.map { it.text }) + assertTrue(body.component.preferredSize.height in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test glob header popup is absent when expanded and leaks no editors`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(GlobToolView(tool().also { it.output = "src/A.kt" })) + assertNotNull(view.headerPopup()) + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + assertEquals(base, EditorFactory.getInstance().allEditors.size) + view.toggle() + assertNull(view.headerPopup()) + } + private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } private fun track(view: GlobToolView): GlobToolView { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt index fcdf2d85b18..b45fb06d0cd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -167,7 +167,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() { } } - fun `test description uses hintFont not editor font family`() { + fun `test description uses secondary font not editor font family`() { edt { val view = LoginRequiredView(openProfile = {}, dismiss = {}) view.show("Sign in required.") @@ -180,7 +180,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() { "Description font should not use editor font family", desc!!.font.name == "Courier New", ) - assertEquals("Description font should equal hintFont", style.hintFont, desc.font) + assertEquals("Description font should equal secondary text font", SessionUiStyle.Text.Secondary.font(style), desc.font) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt index cf1e3408db8..e412d44bebd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt @@ -45,4 +45,20 @@ class MessageViewTest : BasePlatformTestCase() { assertTrue("prompt bubble is a direct child", wrapIndex >= 0) assertTrue("question result stays below the prompt bubble", resultIndex > wrapIndex) } + + // An empty message must stay hidden so SessionLayout skips it (no stray gap/stripe at the top of a + // turn), and must reappear as soon as content arrives — then hide again if that content is removed. + fun `test empty message is hidden until content arrives`() { + val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0))) + val view = MessageView(msg, openFile = { _, _ -> }) + assertFalse("empty message starts hidden", view.isVisible) + + val text = Text("p1").also { it.content.append("hi") } + msg.parts["p1"] = text + view.upsertPart(text) + assertTrue("message shows once it has content", view.isVisible) + + view.removePart("p1") + assertFalse("message hides again when content is removed", view.isVisible) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index d1f83c72ebb..19e5ff0e6a6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.md.MdView import com.intellij.openapi.editor.colors.CodeInsightColors @@ -26,6 +27,16 @@ class PlanExitViewTest : BasePlatformTestCase() { assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown()) } + fun `test plan exit is indented to the regular header leading edge`() { + val view = PlanExitView(tool(ToolExecState.COMPLETED), openFile = { _, _ -> }) + + val ins = view.border.getBorderInsets(view) + assertEquals(SessionUiStyle.View.Header.left(), ins.left) + assertEquals(0, ins.top) + assertEquals(0, ins.bottom) + assertEquals(0, ins.right) + } + fun `test view factory replaces running tool with plan exit view when completed`() { val running = tool(ToolExecState.RUNNING) val existing = ViewFactory.create(running, { _, _ -> }) {} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt new file mode 100644 index 00000000000..faf6ede7419 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PopupTestSupport.kt @@ -0,0 +1,15 @@ +package ai.kilocode.client.session.views + +import com.intellij.ui.EditorTextField +import javax.swing.JComponent + +/** Collects every code editor nested anywhere inside a built header popup body. */ +internal fun popupEditors(root: JComponent): List { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is EditorTextField) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index cfed8dcef3b..38b2cee9a11 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -10,15 +10,12 @@ import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBTextArea -import java.awt.Color import java.awt.Component import java.awt.Container import java.awt.event.MouseEvent -import java.awt.image.BufferedImage import javax.swing.Icon import javax.swing.JLabel import javax.swing.JPanel -import javax.swing.border.Border @Suppress("UnstableApiUsage") class QuestionResultViewTest : BasePlatformTestCase() { @@ -113,8 +110,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { )) view.toggle() - val root = view.node(0) - val body = root.node(1) + val body = view.node(1) val row = body.node(0) val text = row.components[0] as JBTextArea val ins = text.border.getBorderInsets(text) @@ -174,21 +170,18 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), )) - val root = view.node(0) - val header = root.node(0) + val row = view.node(0) - assertEquals(0, paint(root.border).alpha) + assertNull("collapsed card draws no outline", view.border) view.toggle() - val body = root.node(1) view.setHovered(true) - assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header.background.rgb) - assertLine(root.border) - assertEquals(SessionUiStyle.View.Outline.brightColor().rgb, paint(body.border).rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + assertNull("expanded card draws no outline", view.border) view.setHovered(false) - assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header.background.rgb) - assertLine(root.border) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + assertNull("expanded card draws no outline", view.border) } // ------ view factory routing ------ @@ -326,28 +319,6 @@ class QuestionResultViewTest : BasePlatformTestCase() { )) } - private fun paint(border: Border): Color { - val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) - val panel = JPanel() - val graphics = image.createGraphics() - border.paintBorder(panel, graphics, 0, 0, image.width, image.height) - graphics.dispose() - return Color(image.getRGB(0, 0), true) - } - - private fun assertLine(border: Border) { - val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) - val panel = JPanel() - val graphics = image.createGraphics() - border.paintBorder(panel, graphics, 0, 0, image.width, image.height) - graphics.dispose() - val rgb = SessionUiStyle.View.Outline.brightColor().rgb - assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) - assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) - assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) - assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) - } - private fun icons(component: Component): List { val found = mutableListOf() collect(component, found) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 1f19245a780..f8de550a723 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -272,7 +272,7 @@ class QuestionViewTest : BasePlatformTestCase() { ) } - fun `test question title uses headerFont and hint uses hintFont`() { + fun `test question title uses headerFont and hint uses secondary font`() { view.show(singleSelectQuestion("q_fonts")) val style = SessionEditorStyle.current() @@ -280,7 +280,7 @@ class QuestionViewTest : BasePlatformTestCase() { val hint = text(view, "Select one answer") assertEquals("title should use headerFont", style.headerFont, title.font) - assertEquals("hint should use hintFont", style.hintFont, hint.font) + assertEquals("hint should use secondary text font", SessionUiStyle.Text.Secondary.font(style), hint.font) } fun `test custom answer editor uses prompt text styling`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index 685059de343..38691179f6b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -3,7 +3,6 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView @@ -18,9 +17,7 @@ class ReadToolViewTest : BasePlatformTestCase() { val t = tool().also { it.input = mapOf("filePath" to "README.MD") } val view = ReadToolView(t) - val base: Any = view - assertTrue(base is SecondarySessionPartView) assertTrue(view.labelText().contains("Read")) assertTrue(view.labelText().contains("README.MD")) } @@ -82,7 +79,7 @@ class ReadToolViewTest : BasePlatformTestCase() { assertTrue(view.labelText().contains(path)) } - fun `test read output is secondary non expandable summary`() { + fun `test read output is non expandable summary without body`() { val t = tool().also { it.output = "file contents" } val view = ReadToolView(t) @@ -90,8 +87,8 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) assertFalse(view.bodyVisible()) assertEquals("file contents", view.bodyText()) - assertTrue(view.bodyCreated()) - assertTrue(view.bodyWrap()) + assertFalse(view.bodyCreated()) + assertFalse(view.bodyWrap()) assertNull(view.bodyEditor()) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index a35c86b0ba8..4b80bc69cbb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -3,13 +3,13 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout import java.awt.Component import java.awt.Container import javax.swing.Icon @@ -23,10 +23,8 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test completed reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) - val base: Any = view assertFalse(view.isExpanded()) - assertTrue(base is SecondarySessionPartView) assertEquals("Reasoning", view.headerText()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) assertTrue(view.hasToggle()) @@ -336,18 +334,15 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals(0, scroll.verticalScrollBar.value) } - fun `test reasoning block uses vertical separator`() { + fun `test reasoning draws no separator and gaps the body`() { val view = ReasoningView(reasoning("p1", done = true, text = "one")) - assertEquals(1, view.border!!.getBorderInsets(view).left) + assertNull("collapsed reasoning draws no separator", view.border) view.toggle() - val insets = view.border!!.getBorderInsets(view) - assertEquals(0, insets.top) - assertEquals(1, insets.left) - assertEquals(0, insets.bottom) - assertEquals(0, insets.right) + assertNull("expanded reasoning draws no separator", view.border) + assertEquals(SessionUiStyle.View.contentGap(), (view.layout as BorderLayout).vgap) assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) } @@ -404,16 +399,6 @@ class ReasoningViewTest : BasePlatformTestCase() { return found } - private fun popupEditors(root: JComponent): List { - val found = mutableListOf() - fun visit(component: JComponent) { - if (component is com.intellij.ui.EditorTextField) found.add(component) - component.components.filterIsInstance().forEach(::visit) - } - visit(root) - return found - } - private fun popupScrollPanes(root: JComponent): List { val found = mutableListOf() fun visit(component: JComponent) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt index c6009b07477..ac803b2ba82 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -4,15 +4,16 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Container import java.awt.Dimension @@ -35,9 +36,7 @@ class SearchToolViewTest : BasePlatformTestCase() { val view = SearchToolView(tool().also { it.input = mapOf("pattern" to "class SearchToolView", "include" to "*.{kt,kts}") }) - val base: Any = view - assertTrue(base is SecondarySessionPartView) assertTrue(view.labelText().contains("Search")) assertEquals(listOf("pattern=class SearchToolView", "include=*.{kt,kts}"), view.targetTexts()) assertTrue(view.targetVisible(0)) @@ -205,6 +204,41 @@ class SearchToolViewTest : BasePlatformTestCase() { assertFalse(ViewFactory.shouldReplace(SearchToolView(search), search)) } + fun `test search header popup previews results when collapsed`() { + val view = track(SearchToolView(tool().also { + it.input = mapOf("pattern" to "foo") + it.output = "a.kt:1: foo\nb.kt:2: foo" + })) + val body = view.headerPopup()!!.build() + try { + val editors = popupEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("a.kt:1: foo\nb.kt:2: foo"), editors.map { it.text }) + assertTrue(body.component.preferredSize.height in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test search header popup is absent when expanded`() { + val view = track(SearchToolView(tool().also { it.output = "hit" })) + assertNotNull(view.headerPopup()) + view.toggle() + assertNull(view.headerPopup()) + } + + fun `test search header popup leaks no editors after churn`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(SearchToolView(tool().also { it.output = "hit" })) + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + private fun layout(root: Container) { root.doLayout() root.components.filterIsInstance().forEach { layout(it) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 03cd9d4d2ba..3000d87ba13 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -3,19 +3,18 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.SessionSurfacePanel import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView -import ai.kilocode.client.ui.UiStyle import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI @@ -37,7 +36,7 @@ class ShellToolViewTest : BasePlatformTestCase() { } } - fun `test command only shell renders markdown`() { + fun `test command only shell renders one code surface without labels`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") })) assertTrue(view.hasToggle()) @@ -45,21 +44,23 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals("pwd", view.bodyText()) view.toggle() - assertEquals("**Command**\n\n```bash\npwd\n```", view.markdown()) + assertEquals("```bash\npwd\n```", view.markdown()) assertEquals(listOf("pwd"), view.codeTexts()) + assertVisibleSurfaces(view, 1) } - fun `test output only shell renders markdown`() { + fun `test output only shell renders one code surface without labels`() { val view = track(ShellToolView(tool().also { it.output = "done" })) assertEquals("done", view.outputText()) view.toggle() - assertEquals("**Output**\n\n```shell-output\ndone\n```", view.markdown()) + assertEquals("```shell-output\ndone\n```", view.markdown()) assertEquals(listOf("done"), view.codeTexts()) + assertVisibleSurfaces(view, 1) } - fun `test command and output render sections in order`() { + fun `test command and output render two surfaces in order`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "git status", "description" to "Check status") it.output = "clean" @@ -72,11 +73,9 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals("git status\n\nclean", view.bodyText()) view.toggle() - assertEquals( - "**Command**\n\n```bash\ngit status\n```\n\n**Output**\n\n```shell-output\nclean\n```", - view.markdown(), - ) + assertEquals("```bash\ngit status\n```\n\n```shell-output\nclean\n```", view.markdown()) assertEquals(listOf("git status", "clean"), view.codeTexts()) + assertVisibleSurfaces(view, 2) } fun `test shell header subtitle is normalized to one html line`() { @@ -144,7 +143,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(listOf("before\n```\nafter"), view.codeTexts()) } - fun `test error section uses shell error text`() { + fun `test error renders as its own code surface`() { val view = track(ShellToolView(tool(ToolExecState.ERROR).also { it.input = mapOf("command" to "fail") it.error = "boom" @@ -154,10 +153,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(view.labelText().contains("Error")) view.toggle() - assertEquals( - "**Command**\n\n```bash\nfail\n```\n\n**Error**\n\n```ansi-stderr\nboom\n```", - view.markdown(), - ) + assertEquals("```bash\nfail\n```\n\n```ansi-stderr\nboom\n```", view.markdown()) assertEquals(listOf("fail", "boom"), view.codeTexts()) val error = view.codeEditors().last().getEditor(true)!! val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey @@ -171,14 +167,18 @@ class ShellToolViewTest : BasePlatformTestCase() { })) assertFalse(view.bodyCreated()) + assertNull(view.content()) + assertTrue(view.surfaces().isEmpty()) + assertTrue(view.codeEditors().isEmpty()) + assertTrue(view.scrolls().isEmpty()) view.toggle() - val body = view.mdComponent() + val body = view.content() val cmd = view.codeEditors().first() val out = view.codeEditors().last() view.toggle() view.toggle() - assertSame(body, view.mdComponent()) + assertSame(body, view.content()) assertSame(cmd, view.codeEditors().first()) assertSame(out, view.codeEditors().last()) assertTrue(view.bodyVisible()) @@ -207,7 +207,7 @@ class ShellToolViewTest : BasePlatformTestCase() { view.toggle() view.toggle() - val body = view.mdComponent() + val body = view.content() val cmd = view.codeEditors().first() val out = view.codeEditors().last() view.update(tool().also { @@ -217,7 +217,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyCreated()) assertFalse(view.bodyVisible()) - assertSame(body, view.mdComponent()) + assertSame(body, view.content()) assertSame(cmd, view.codeEditors().first()) assertSame(out, view.codeEditors().last()) assertEquals(listOf("pwd", "/home"), view.codeTexts()) @@ -239,7 +239,7 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(style.transcriptFont.name, view.subtitleFont().name) assertEquals(style.transcriptFont.size, view.subtitleFont().size) assertFalse(view.subtitleFont().isBold) - assertEquals(UiStyle.Colors.weak().rgb, view.subtitleForeground().rgb) + assertEquals(SessionUiStyle.Text.Secondary.foreground().rgb, view.subtitleForeground().rgb) assertTrue(view.stateFont().size < style.editorSize) } @@ -265,6 +265,49 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(view.preferredSize.height > 0) } + fun `test command and output are separate rounded surfaces with a transparent footer slot`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + view.toggle() + val surfaces = view.surfaces() + + assertEquals(2, surfaces.size) + surfaces.forEach { + assertTrue("each content piece is a rounded code surface", it is SessionSurfacePanel) + assertEquals(SessionUiStyle.Colors.codeBlockBackground().rgb, it.background.rgb) + } + // Two independent code editors — no shared body, no "Command"/"Output" labels. + assertEquals(2, view.codeEditors().size) + // The footer region stays empty (and transparent) until a view adds an ambient note. + assertFalse(view.content()!!.hasFooter()) + } + + fun `test shell code blocks are editor backed and capped to fifteen lines`() { + val output = (1..30).joinToString("\n") { "line $it" } + val view = track(ShellToolView(tool().also { it.output = output })) + view.toggle() + val pane = view.scrolls().single() + val editor = view.codeEditors().single() + val nested = editor.getEditor(true)!!.scrollPane + val line = editor.getEditor(true)!!.lineHeight + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(output, editor.text) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, nested.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, nested.horizontalScrollBarPolicy) + assertFalse(editor.getEditor(true)!!.settings.isUseSoftWraps) + assertTrue(pane.preferredSize.height <= line * 15 + chrome) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + fun `test plain git output receives shell output highlighters`() { val output = """ 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 @@ -312,61 +355,6 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) } - fun `test shell labels align with code text and code blocks use bottom border only`() { - val view = track(ShellToolView(tool().also { - it.input = mapOf("command" to "pwd") - it.output = "/tmp" - })) - view.toggle() - val root = view.mdComponent()!! - val ins = root.border?.getBorderInsets(root) - val labels = root.components.filterIsInstance() - val panes = root.components.filterIsInstance() - - assertEquals(0, ins?.left ?: 0) - assertEquals(2, labels.size) - assertEquals(2, panes.size) - labels.forEach { - val label = it.border?.getBorderInsets(it) - assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), label?.left ?: 0) - assertEquals(0, label?.right ?: 0) - } - panes.forEach { - val pane = it.border.getBorderInsets(it) - assertEquals(0, pane.top) - assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.bottom) - assertEquals(0, pane.left) - assertEquals(0, pane.right) - assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, it.verticalScrollBarPolicy) - assertEquals(root.background.rgb, it.background.rgb) - assertEquals(root.background.rgb, it.viewport.background.rgb) - } - } - - fun `test shell code blocks are editor backed and capped to fifteen lines`() { - val output = (1..30).joinToString("\n") { "line $it" } - val view = track(ShellToolView(tool().also { it.output = output })) - view.toggle() - val pane = view.mdComponent()!!.components.filterIsInstance().single() - val editor = view.codeEditors().single() - val nested = editor.getEditor(true)!!.scrollPane - val line = editor.getEditor(true)!!.lineHeight - val chrome = pane.insets.top + pane.insets.bottom + - pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + - pane.horizontalScrollBar.preferredSize.height - - assertEquals(output, editor.text) - assertEquals(1, view.codeEditors().size) - assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) - assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, nested.verticalScrollBarPolicy) - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, nested.horizontalScrollBarPolicy) - assertFalse(editor.getEditor(true)!!.settings.isUseSoftWraps) - assertTrue(pane.preferredSize.height <= line * 15 + chrome) - assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) - assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) - } - fun `test shell header popup is available for collapsed command`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd", "description" to "Short") @@ -427,6 +415,27 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } + fun `test shell header popup includes output and disposes both editors after hide`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git status") + it.output = "on branch main" + })) + val req = view.headerPopup()!! + val body = req.build() + + try { + val editors = popupCodeEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("git status", "on branch main"), editors.map { it.text }) + } finally { + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + fun `test shell header popup widens to command content`() { val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "echo ${"x".repeat(180)}") @@ -523,6 +532,10 @@ class ShellToolViewTest : BasePlatformTestCase() { private fun ShellToolView.codeTexts() = codeEditors().map { it.text } + private fun assertVisibleSurfaces(view: ShellToolView, expected: Int) { + assertEquals(expected, view.surfaces().count { it.isVisible }) + } + private fun tool(state: ToolExecState = ToolExecState.COMPLETED) = Tool("p1", "bash", toolKind("bash")).also { it.state = state } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index 8b70bcf35a4..01811084b00 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -4,7 +4,6 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack @@ -33,12 +32,6 @@ class TaskToolViewTest : BasePlatformTestCase() { } } - fun `test task tool uses secondary chrome`() { - val base: Any = view(task()) - - assertTrue(base is SecondarySessionPartView) - } - fun `test task header shows agent description and count`() { val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep")))) @@ -110,7 +103,7 @@ class TaskToolViewTest : BasePlatformTestCase() { fun `test child tool titles use target color`() { val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep", ToolExecState.ERROR)))) - assertColor(UiStyle.Colors.weak(), titleColor(view, 0)) + assertColor(SessionUiStyle.Text.Secondary.foreground(), titleColor(view, 0)) assertColor(UiStyle.Colors.errorLabelForeground(), titleColor(view, 1)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index d8f4d3e9fb2..ba9b2b9eeb9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -6,19 +6,17 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.ToolView import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.util.Disposer +import com.intellij.openapi.editor.EditorFactory import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.scale.JBUIScale import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout -import java.awt.Color -import java.awt.image.BufferedImage import javax.swing.JPanel import javax.swing.ScrollPaneConstants -import javax.swing.border.Border /** * Tests for [ToolView]. @@ -118,31 +116,18 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(spans.contains("-v" to DefaultLanguageHighlighterColors.KEYWORD)) } - fun `test bash tool uses secondary chrome`() { - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) - val base: Any = view - - assertTrue(base is SecondarySessionPartView) - } - - fun `test unknown tool uses secondary chrome`() { - val view = ToolView(tool("p1", "mystery", ToolExecState.COMPLETED)) - val base: Any = view - - assertTrue(base is SecondarySessionPartView) - } - - fun `test tool outline is drawn only while expanded`() { + fun `test tool draws no outline and separates the body with the standard gap`() { val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" })) - assertEquals(0, paint(view.border).alpha) + assertNull("collapsed card draws no outline", view.border) view.toggle() - assertEquals(SessionUiStyle.View.Outline.color().rgb, paint(view.border).rgb) + assertNull("expanded card draws no outline", view.border) + assertEquals(SessionUiStyle.View.contentGap(), (view.layout as BorderLayout).vgap) view.toggle() - assertEquals(0, paint(view.border).alpha) + assertNull("collapsed card draws no outline", view.border) } fun `test bash toggle collapses and expands`() { @@ -409,6 +394,45 @@ class ToolViewTest : BasePlatformTestCase() { assertEquals("part99", view.contentId) } + // ---- header popup ------ + + fun `test tool header popup previews output when collapsed`() { + val view = track(ToolView(tool("g1", "grep", ToolExecState.COMPLETED).also { it.output = "match one\nmatch two" })) + val req = view.headerPopup() + assertNotNull(req) + val body = req!!.build() + try { + val editors = popupEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals(listOf("match one\nmatch two"), editors.map { it.text }) + assertTrue(body.component.preferredSize.height in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test tool header popup is absent when empty or expanded`() { + val empty = track(ToolView(tool("g2", "grep", ToolExecState.COMPLETED))) + assertNull(empty.headerPopup()) + + val view = track(ToolView(tool("g3", "grep", ToolExecState.COMPLETED).also { it.output = "hit" })) + assertNotNull(view.headerPopup()) + view.toggle() + assertNull(view.headerPopup()) + } + + fun `test tool header popup disposes editor after hide and churn`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(ToolView(tool("g4", "grep", ToolExecState.COMPLETED).also { it.output = "hit" })) + repeat(20) { + val body = view.headerPopup()!!.build() + popupEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + // ---- helpers ------ private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = @@ -440,12 +464,4 @@ class ToolViewTest : BasePlatformTestCase() { return (header.layout as BorderLayout).hgap } - private fun paint(border: Border): Color { - val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) - val item = JPanel() - val graphics = image.createGraphics() - border.paintBorder(item, graphics, 0, 0, image.width, image.height) - graphics.dispose() - return Color(image.getRGB(0, 0), true) - } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 4b2e38e1fcf..8cdb5c9cba6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -144,14 +144,22 @@ class TurnViewTest : BasePlatformTestCase() { assertFalse(mv.isOpaque) } - fun `test user message uses standard outline color`() { + // An empty user message is a bare turn anchor with no parts. It must paint nothing: painting the + // prompt surface on its ~1px-tall bounds would leave a thin light stripe at the top of the turn. + fun `test empty user message paints no prompt surface stripe`() { val mv = MessageView(msg("u1", "user"), openFile) - mv.setSize(120, 48) - val image = BufferedImage(120, 48, BufferedImage.TYPE_INT_ARGB) + mv.setSize(120, 4) + val image = BufferedImage(120, 4, BufferedImage.TYPE_INT_ARGB) - mv.paint(image.createGraphics()) + val g = image.createGraphics() + mv.paint(g) + g.dispose() - assertEquals(SessionUiStyle.View.Outline.color().rgb, image.getRGB(60, 0)) + for (x in 0 until 120) { + for (y in 0 until 4) { + assertEquals("pixel ($x,$y) must stay transparent", 0, (image.getRGB(x, y) ushr 24) and 0xff) + } + } } fun `test assistant message remains borderless`() { @@ -369,8 +377,8 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test consecutive messages use shared compact gap`() { - val tv = TurnView("u1", openFile) - tv.addMessage(msg("u1", "user").also { msg -> + val tv = TurnView("a1", openFile) + tv.addMessage(msg("a1", "assistant").also { msg -> msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } }) tv.addMessage(msg("a2", "assistant").also { msg -> @@ -379,7 +387,7 @@ class TurnViewTest : BasePlatformTestCase() { tv.setSize(400, 300) tv.doLayout() - val first = tv.messageView("u1")!! + val first = tv.messageView("a1")!! val second = tv.messageView("a2")!! assertEquals(JBUI.scale(SessionUiStyle.SessionLayout.GAP), second.y - first.bounds.maxY.toInt()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 6fb2cde06f5..8f1004f729e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -4,14 +4,16 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Color import java.awt.Component +import java.awt.Cursor import java.awt.event.MouseEvent import java.awt.image.BufferedImage import javax.swing.Icon +import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel -import javax.swing.border.Border @Suppress("UnstableApiUsage") class AbstractSessionPartViewTest : BasePlatformTestCase() { @@ -87,29 +89,144 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertNull(content.parent) } + fun `test expandable card header shows the hand cursor`() { + val view = TestView(content = JLabel("body")) + + assertEquals(Cursor.HAND_CURSOR, (view.component(0) as JPanel).cursor.type) + } + + fun `test expandable card applies the hand cursor to nested header children`() { + val child = JLabel("plain") + val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } + NestedView(header) + + assertEquals(Cursor.HAND_CURSOR, child.cursor.type) + } + + fun `test fixed non expandable card keeps the default cursor`() { + val view = TestView(content = JLabel("body"), expandable = false) + + assertEquals(Cursor.DEFAULT_CURSOR, (view.component(0) as JPanel).cursor.type) + } + fun `test header hover fill differs from outline colors`() { + assertEquals(SessionUiStyle.Colors.sessionBackground(), SessionUiStyle.View.Surface.headerBgColor()) + assertEquals(SessionUiStyle.Colors.sessionBackground(), SessionUiStyle.View.Surface.bgColor()) assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.hoverColor()) assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.brightColor()) } - fun `test primary card hover only changes header background`() { + fun `test hover only changes header background and draws no card outline`() { val view = TestView(content = JLabel("body")) val row = view.component(0) as JPanel - assertEquals(0, paint(view.border).alpha) + assertNull("collapsed card has no outline", view.border) view.expand() view.setHovered(true) assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) - assertLine(view.border) + assertNull("expanded card draws no outline", view.border) view.setHovered(false) assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) - assertLine(view.border) + assertNull("expanded card draws no outline", view.border) } - private class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : - PrimarySessionPartView(JLabel("header"), content, expanded, expandable) { + fun `test expanded body is separated from header by the standard gap`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true) + + val layout = view.layout as BorderLayout + assertEquals(SessionUiStyle.View.contentGap(), layout.vgap) + assertSame(view, content.parent) + } + + fun `test collapsed card hover fill is rounded`() { + val view = TestView(content = JLabel("body")) + val row = view.component(0) as JPanel + row.setSize(40, 40) + view.setHovered(true) + + val image = paintRow(row) + val hover = SessionUiStyle.View.Surface.headerHoverBgColor().rgb + + assertEquals("center is filled with the hover color", hover, image.getRGB(20, 2)) + assertEquals("rounded corner is transparent", 0, image.getRGB(0, 0) ushr 24) + } + + fun `test expanded card hover fill stays rounded`() { + val view = TestView(content = JLabel("body")) + view.expand() + val row = view.component(0) as JPanel + row.setSize(40, 40) + view.setHovered(true) + + val image = paintRow(row) + val hover = SessionUiStyle.View.Surface.headerHoverBgColor().rgb + + assertEquals("center is filled with the hover color", hover, image.getRGB(20, 2)) + assertEquals("expanded header keeps the rounded corner transparent", 0, image.getRGB(0, 0) ushr 24) + } + + private fun paintRow(row: JPanel): BufferedImage { + val image = BufferedImage(40, 40, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + row.paint(graphics) + graphics.dispose() + return image + } + + fun `test hover tracks nested header child and clears on leave`() { + val child = JLabel("link") + val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } + val view = NestedView(header) + val row = view.component(0) as JPanel + view.setSize(200, 40) + view.doLayout() + + // A nested child that is not click-bound (e.g. a file link) must still report hover. + enter(child) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + + // Leaving the row through that nested child clears the fill instead of leaving it stuck. + exit(child, 10_000, 10_000) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + } + + fun `test clicking a nested header child toggles the card`() { + val child = JLabel("plain") + val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } + val view = NestedView(header) + view.setSize(200, 40) + view.doLayout() + + assertFalse(view.isExpanded()) + click(child) + assertTrue("Clicking anywhere on the header toggles the card", view.isExpanded()) + click(child) + assertFalse(view.isExpanded()) + } + + fun `test clicking an interactive header child runs its action without toggling`() { + var clicks = 0 + val child = JLabel("link").apply { + addMouseListener(object : java.awt.event.MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { clicks++ } + }) + } + val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } + val view = NestedView(header) + view.setSize(200, 40) + view.doLayout() + + click(child) + + assertEquals("The child's own action runs", 1, clicks) + assertFalse("A control with its own click handler must not also toggle", view.isExpanded()) + } + + private open class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : + AbstractSessionPartView(JLabel("header"), content, expanded, expandable) { override val contentId = "test" override fun update(content: Content) {} @@ -117,47 +234,32 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { fun arrowIcon(): Icon = arrow.icon } - private fun TestView.component(index: Int): Component = components[index] + private class NestedView(header: JComponent) : AbstractSessionPartView(header, JLabel("body")) { + override val contentId = "nested" + override fun update(content: Content) {} + } - private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED) + private fun AbstractSessionPartView.component(index: Int): Component = components[index] - private fun exit(component: Component) = event(component, MouseEvent.MOUSE_EXITED) + private fun click(component: Component) = event(component, MouseEvent.MOUSE_CLICKED, 1, 1) - private fun event(component: Component, id: Int) { + private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED, 1, 1) + + private fun exit(component: Component, x: Int = 1, y: Int = 1) = event(component, MouseEvent.MOUSE_EXITED, x, y) + + private fun event(component: Component, id: Int, x: Int, y: Int) { component.dispatchEvent(MouseEvent( component, id, System.currentTimeMillis(), 0, - 1, - 1, + x, + y, 0, false, )) } - private fun paint(border: Border): Color { - val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) - val panel = JPanel() - val graphics = image.createGraphics() - border.paintBorder(panel, graphics, 0, 0, image.width, image.height) - graphics.dispose() - return Color(image.getRGB(0, 0), true) - } - - private fun assertLine(border: Border) { - val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) - val panel = JPanel() - val graphics = image.createGraphics() - border.paintBorder(panel, graphics, 0, 0, image.width, image.height) - graphics.dispose() - val rgb = SessionUiStyle.View.Outline.color().rgb - assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) - assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) - assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) - assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) - } - private fun assertNotSameColor(left: Color, right: Color) { assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 522e7b41b6a..740627998da 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -250,15 +250,18 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } - fun `test action buttons use question card surface background`() { + fun `test action buttons are non-opaque so no stray fill frame`() { edt { val panel = BaseQuestionView() panel.setActions(listOf( BaseQuestionView.Action("a", "A", primary = false) {}, BaseQuestionView.Action("b", "B", primary = true) {}, )) - assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "A").background) - assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "B").background) + // Non-opaque so Swing does not fill the rectangular bounds with the component + // background before DarculaButtonUI paints the rounded shape. That rectangle leaked + // as a stray frame around the button in the Islands Light theme. + assertFalse(actionButton(panel, "A").isOpaque) + assertFalse(actionButton(panel, "B").isOpaque) } } @@ -395,7 +398,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { // ------ applyStyle: UI fonts ---- - fun `test applyStyle applies headerFont to header and hintFont to description`() { + fun `test applyStyle applies headerFont to header and secondary font to description`() { edt { val panel = BaseQuestionView() panel.setHeader("Title", "Hint") @@ -406,7 +409,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val desc = areas.first { it.text == "Hint" } assertEquals("headerText should use headerFont", style.headerFont, header.font) - assertEquals("descriptionText should use hintFont", style.hintFont, desc.font) + assertEquals("descriptionText should use secondary text font", SessionUiStyle.Text.Secondary.font(style), desc.font) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index efad300f3e9..b5a3e8519e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -5,15 +5,17 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color +import java.awt.image.BufferedImage +import javax.swing.JComponent import javax.swing.JPanel @Suppress("UnstableApiUsage") @@ -33,10 +35,8 @@ class TodoWriteViewTest : BasePlatformTestCase() { TodoDto("Next", "pending", "medium"), ) }) - val base: Any = view assertTrue(view.labelText().contains("To-dos")) - assertTrue(base is PrimarySessionPartView) assertTrue(view.labelText().contains("1/2")) assertTrue(view.isExpanded()) assertEquals(2, view.rowCount()) @@ -51,6 +51,25 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals("Pending to-do: Next", view.rowCheckAccessibleName(1)) } + fun `test todo content is a rounded editor-background surface`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("A", "pending", "medium")) + }) + val list = view.components.filterIsInstance().single() + + // The todo list is the raised editor surface, but rounded like the header: non-opaque so the + // corners reveal the transparent card body, filled with the editor background in the middle. + assertFalse(list.isOpaque) + + list.setSize(80, 80) + val image = BufferedImage(80, 80, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + list.paint(graphics) + graphics.dispose() + assertEquals("rounded corner reveals the backdrop", 0, image.getRGB(0, 0) ushr 24) + assertEquals(SessionUiStyle.Colors.codeBlockBackground().rgb, image.getRGB(40, 4)) + } + fun `test pending rows keep normal foreground`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf( @@ -95,7 +114,9 @@ class TodoWriteViewTest : BasePlatformTestCase() { val body = view.components.filterIsInstance().single() val ins = body.border.getBorderInsets(body) - assertEquals(UiStyle.Gap.lg() + SessionUiStyle.View.Outline.width(), ins.top) + // No separator line: just the standard content padding, so the list reads as transparent + // content set apart from the header by the base card's transparent gap. + assertEquals(UiStyle.Gap.lg(), ins.top) assertEquals(UiStyle.Gap.pad(), ins.left) assertEquals(UiStyle.Gap.lg(), ins.bottom) assertEquals(UiStyle.Gap.pad(), ins.right) @@ -163,5 +184,66 @@ class TodoWriteViewTest : BasePlatformTestCase() { return (header.layout as BorderLayout).hgap } + fun `test todo header popup shows when collapsed and lists todos`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Done", "completed", "high"), TodoDto("Next", "pending", "medium")) + }) + assertTrue(view.isExpanded()) + assertNull(view.headerPopup()) + + view.toggle() + assertFalse(view.isExpanded()) + val body = view.headerPopup()!!.build() + try { + val list = popupTodoList(body.component) + assertEquals(2, list.rowCount()) + assertTrue(list.rowChecked(0)) + assertFalse(list.rowChecked(1)) + assertTrue(list.rowText(0).contains("Done")) + assertTrue(list.rowText(1).contains("Next")) + assertEquals(view.rowCheckBackground(0), list.rowCheckBackground(0)) + assertEquals(view.rowCheckForeground(0), list.rowCheckForeground(0)) + assertEquals(view.rowCheckBorder(0), list.rowCheckBorder(0)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test todo header popup uses compact todo view rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Hidden", "pending", "medium")) + it.todoView = TodoViewDto( + mode = "compact", + todos = listOf(TodoDto("Visible", "pending", "high", changed = true)), + hiddenBefore = 2, + hiddenAfter = 3, + ) + }) + view.toggle() + val body = view.headerPopup()!!.build() + try { + val list = popupTodoList(body.component) + assertEquals(1, list.rowCount()) + assertTrue(list.rowText(0).contains("Visible")) + assertEquals(view.hiddenText(), list.hiddenText()) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test todo header popup is absent without todos`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED)) + view.toggle() + assertNull(view.headerPopup()) + } + + private fun popupTodoList(root: JComponent): TodoListPanel { + fun visit(component: JComponent): TodoListPanel? { + if (component is TodoListPanel) return component + return component.components.filterIsInstance().firstNotNullOfOrNull(::visit) + } + return visit(root) ?: error("TodoListPanel not found") + } + private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index a8f9b91f412..d5cde117805 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -26,6 +26,7 @@ import java.awt.Color import java.awt.Point import java.awt.datatransfer.DataFlavor import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import java.net.URI import javax.swing.Box import javax.swing.JPanel @@ -88,7 +89,7 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(0, pane.verticalScrollBar.preferredSize.width) } - fun `test transparent markdown keeps code block background opaque`() { + fun `test transparent markdown keeps code block surface filled`() { view.opaque = false view.set("```kotlin\nval value = 1\n```") @@ -97,7 +98,9 @@ class MdViewHybridTest : BasePlatformTestCase() { val bg = view.preBg assertFalse(view.component.isOpaque) - assertTrue(pane.isOpaque) + // The pane paints its own rounded surface fill, so it stays non-opaque; the inner viewport + // and editor keep the surface color. + assertFalse(pane.isOpaque) assertTrue(pane.viewport.isOpaque) assertTrue(editor.scrollPane.isOpaque) assertTrue(editor.scrollPane.viewport.isOpaque) @@ -108,6 +111,37 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(bg.rgb, editor.scrollPane.viewport.background.rgb) } + fun `test fenced code block corners are rounded`() { + view.set("```kotlin\nval x = 1\n```") + val pane = scrolls().single() + pane.setSize(200, 60) + pane.doLayout() + + val image = BufferedImage(200, 60, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + pane.paint(g) + g.dispose() + val fill = view.preBg.rgb + + assertEquals("surface fills along the top edge", fill, image.getRGB(100, 2)) + assertFalse("rounded corner is left unfilled", fill == image.getRGB(0, 0)) + } + + fun `test wide code block clips scrollbar to rounded bottom corners`() { + view.set("```kotlin\n${"x".repeat(500)}\n```") + val pane = scrolls().single() + pane.setSize(160, pane.preferredSize.height) + pane.doLayout() + + val image = BufferedImage(160, pane.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + pane.paint(g) + g.dispose() + + assertTrue("wide content shows the horizontal scrollbar", pane.horizontalScrollBar.isVisible) + assertEquals("bottom corner stays transparent", 0, image.getRGB(0, pane.height - 1) ushr 24) + } + fun `test fenced code block preserves multiline editor text and height`() { view.set("```kotlin\nval one = 1\nval two = 2\nval three = 3\n```") val pane = scrolls().single() @@ -237,6 +271,7 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.text.contains("")) assertFalse(pane.text.contains("#cc8866")) assertFalse(pane.text.contains("background:")) + assertTrue(view.overrideSheet().contains("border-width: 0")) assertTrue(scrolls().isEmpty()) assertTrue(editors().isEmpty()) } @@ -783,7 +818,7 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("one", editors().single().text) } - fun `test fenced code block width is bounded and boxed`() { + fun `test fenced code block width is bounded and borderless`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() val editor = editors().single() @@ -792,8 +827,10 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(0, pane.preferredSize.width) assertTrue(editor.preferredSize.width > pane.preferredSize.width) assertTrue(pane.maximumSize.width > 1000) - assertTrue(ins.top > 0) - assertTrue(ins.left > 0) + assertEquals(0, ins.top) + assertEquals(0, ins.left) + assertEquals(0, ins.bottom) + assertEquals(0, ins.right) assertEquals(pane.background, pane.viewport.background) } @@ -980,6 +1017,7 @@ class MdViewHybridTest : BasePlatformTestCase() { val field = editors().single() val editor = field.getEditor(true)!! val style = customStyle() + val bg = style.editorBackground.rgb view.applyStyle(style) @@ -989,11 +1027,11 @@ class MdViewHybridTest : BasePlatformTestCase() { Color(0xDD, 0xEE, 0xFF).rgb, editor.colorsScheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK).foregroundColor.rgb, ) - assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.backgroundColor.rgb) - assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.background.rgb) - assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.viewport.background.rgb) - assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.background.rgb) - assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.viewport.background.rgb) + assertEquals(bg, editor.backgroundColor.rgb) + assertEquals(bg, pane.background.rgb) + assertEquals(bg, pane.viewport.background.rgb) + assertEquals(bg, editor.scrollPane.background.rgb) + assertEquals(bg, editor.scrollPane.viewport.background.rgb) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 598483cfe15..f55b0188ea2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -311,9 +311,17 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(pre.contains("border-color:")) } + fun `test override sheet disables html code block borders`() { + val sheet = view.overrideSheet() + val pre = sheet.substringAfter("pre {").substringBefore("} pre code") + + assertTrue(pre.contains("border-width: 0")) + } + fun `test applyStyle derives markdown colors from editor scheme`() { val style = customStyle() val color = MdCommon.hex(SessionUiStyle.View.Markdown.string()) + val pre = MdCommon.hex(style.editorBackground) val quote = "#445566" view.applyStyle(style) @@ -325,7 +333,7 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(html.contains("inline")) assertFalse(html.contains("background: #112233")) assertFalse(html.contains("#cc8866")) - assertTrue(sheet.contains("pre { background: #445566; color: #ddeeff; border-color: #223344")) + assertTrue(sheet.contains("pre { background: $pre; color: #ddeeff; border-color: #223344")) assertTrue(sheet.contains("blockquote { background:")) assertTrue(sheet.contains("border-left-color: #223344; color: $quote")) assertTrue(sheet.contains("blockquote p { color: $quote")) @@ -353,6 +361,13 @@ class MdViewTest : BasePlatformTestCase() { assertFalse(view.html().contains("#102030")) } + fun `test inline code rule disables borders`() { + val sheet = view.overrideSheet() + val code = sheet.substringAfter("tt, code, samp, pre, pre code {").substringBefore("}") + + assertTrue(code.contains("border-width: 0")) + } + fun `test pre bg and fg overrides appear in override sheet`() { view.preBg = Color(0x0A, 0x0B, 0x0C) view.preFg = Color(0xD0, 0xE0, 0xF0) @@ -368,7 +383,7 @@ class MdViewTest : BasePlatformTestCase() { view.set("`x`") val sheet = view.overrideSheet() - assertTrue(sheet.contains("tt, code, samp, pre, pre code { font-family: 'Fira Code', monospace")) + assertTrue(sheet.contains("tt, code, samp, pre, pre code { font-family: 'Fira Code', monospace; border-width: 0")) assertTrue(sheet.contains("a.kilo-file-ref, code a.kilo-file-ref { color:")) assertTrue(sheet.contains("font-family: 'Fira Code', monospace; text-decoration: underline")) } @@ -382,7 +397,7 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(sheet.contains("body { color:")) assertTrue(sheet.contains("font-family: '${style.transcriptFont.name}', sans-serif")) - assertTrue(sheet.contains("tt, code, samp, pre, pre code { font-family: 'Courier New', monospace")) + assertTrue(sheet.contains("tt, code, samp, pre, pre code { font-family: 'Courier New', monospace; border-width: 0")) } fun `test blockquote color overrides appear in override sheet`() { diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 57b5c1fbb58..6102228755f 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.15 +kilo.jetbrains.version=7.0.16 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 4423483ccd7..7fa9b0a3e31 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.21", + "version": "7.4.22", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index 8406d648798..a612e2f3eb9 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-memory/src/commands.ts b/packages/kilo-memory/src/commands.ts index 0b7d48d453d..0170b536740 100644 --- a/packages/kilo-memory/src/commands.ts +++ b/packages/kilo-memory/src/commands.ts @@ -38,6 +38,7 @@ type Help = { type Show = { kind: "show" + rest?: string } type Operation = @@ -64,6 +65,7 @@ type Operation = | { kind: "operation" operation: Exclude + rest?: string } type Usage = { @@ -93,10 +95,10 @@ function usage(reason: string): ParsedMemoryCommand { } function operation(verb: string, text: string): ParsedMemoryCommand | undefined { - if (verb === "on" || verb === "enable") return { kind: "operation", operation: "enable" } - if (verb === "off" || verb === "disable") return { kind: "operation", operation: "disable" } + if (verb === "on" || verb === "enable") return { kind: "operation", operation: "enable", rest: text } + if (verb === "off" || verb === "disable") return { kind: "operation", operation: "disable", rest: text } if (verb === "status" || verb === "inspect" || verb === "rebuild") { - return { kind: "operation", operation: verb } + return { kind: "operation", operation: verb, rest: text } } if (verb === "purge") { if (text.toLowerCase() === "confirm") return { kind: "operation", operation: "purge", confirm: true } @@ -138,7 +140,7 @@ export function parseMemoryCommand(input: string): ParsedMemoryCommand | undefin const parts = split(picked.rest) const verb = parts.head if (!verb) return { kind: "help" } - if (verb === "show") return { kind: "show" } + if (verb === "show") return { kind: "show", rest: parts.tail } const op = operation(verb, parts.tail) if (op) return op diff --git a/packages/kilo-memory/test/command-cases.json b/packages/kilo-memory/test/command-cases.json index dea1a430422..ef7897bf8f4 100644 --- a/packages/kilo-memory/test/command-cases.json +++ b/packages/kilo-memory/test/command-cases.json @@ -19,6 +19,12 @@ "input": "/memory project show", "result": "show" }, + { + "name": "show preserves trailing text", + "input": "/memory show draft notes", + "result": "show", + "rest": "draft notes" + }, { "name": "status operation", "input": "/memory status", @@ -61,6 +67,27 @@ "result": "operation", "operation": "rebuild" }, + { + "name": "rebuild keeps trailing text", + "input": "/memory rebuild hello", + "result": "operation", + "operation": "rebuild", + "rest": "hello" + }, + { + "name": "enable keeps trailing text", + "input": "/memory on hello", + "result": "operation", + "operation": "enable", + "rest": "hello" + }, + { + "name": "project scope rebuild keeps trailing text", + "input": "/memory project rebuild hello", + "result": "operation", + "operation": "rebuild", + "rest": "hello" + }, { "name": "purge requires confirmation", "input": "/memory purge", diff --git a/packages/kilo-memory/test/commands.test.ts b/packages/kilo-memory/test/commands.test.ts index c5661b137a5..58d6d1aa9b7 100644 --- a/packages/kilo-memory/test/commands.test.ts +++ b/packages/kilo-memory/test/commands.test.ts @@ -11,6 +11,7 @@ type Case = { text?: string query?: string reason?: string + rest?: string } const cases = (await Bun.file(new URL("./command-cases.json", import.meta.url)).json()) as Case[] @@ -18,7 +19,7 @@ const cases = (await Bun.file(new URL("./command-cases.json", import.meta.url)). function expected(item: Case): ParsedMemoryCommand | undefined { if (item.result === "none") return if (item.result === "help") return { kind: "help" } - if (item.result === "show") return { kind: "show" } + if (item.result === "show") return { kind: "show", rest: item.rest ?? "" } if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" } if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`) if (item.operation === "remember" || item.operation === "correct") { @@ -37,7 +38,7 @@ function expected(item: Case): ParsedMemoryCommand | undefined { if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`) return { kind: "operation", operation: item.operation, confirm: true } } - return { kind: "operation", operation: item.operation } + return { kind: "operation", operation: item.operation, rest: item.rest ?? "" } } describe("memory commands", () => { diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index fca45939505..d46a44fcd07 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 84cced9801e..91cf054363c 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index fbe3066d9e1..b0059815f47 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 1ddb5a04b6c..4b61ec71b2f 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -6,6 +6,7 @@ import { For, Match, onCleanup, + onMount, Show, Switch, type JSX, @@ -28,6 +29,7 @@ import { QuestionInfo, } from "@kilocode/sdk/v2" import { useData } from "../context" +import { checkFile } from "../file-link-validator" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { useClipboard } from "../context/clipboard" @@ -57,7 +59,7 @@ import { readToolOpen, toolOpenKey } from "./tool-open-state" import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" import { ShellRollingResults } from "./shell-rolling-results" import { reasoningHeading } from "./reasoning-heading" -import { extractFilePathFromHref } from "../file-path" +import { extractFilePathFromHref } from "@opencode-ai/ui/file-path" import { normalize } from "./session-diff" import { deferredHighlight } from "../context/marked" import { escapeHtml } from "../util/escape-html" @@ -1411,11 +1413,150 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { setTimeout(() => setCopied(false), 2000) } + // Post-render: validate file-link candidates against the filesystem. + // Candidates that exist as files get promoted to .file-link; others stay + // plain code. Caching, in-flight de-duplication, and cross-request batching + // all live in file-link-validator.ts's module-level singleton rather than + // here — a per-component cache would be discarded every time virtualization + // unmounts and remounts this row. + let bodyRef: HTMLDivElement | undefined + + const promote = (el: HTMLElement, path: string, exists: boolean) => { + if (!exists) { + el.classList.remove("file-link-candidate") + el.classList.remove("file-link") + el.removeAttribute("data-file-candidate") + el.removeAttribute("data-file-path") + el.removeAttribute("data-file-line") + el.removeAttribute("data-file-col") + return + } + // Strip ./ prefix for the click handler — VS Code resolves relative + // paths against the workspace root, so "./LICENSE" → "LICENSE". + const clean = path.startsWith("./") ? path.slice(2) : path + el.classList.remove("file-link-candidate") + el.classList.add("file-link") + el.setAttribute("data-file-path", clean) + el.removeAttribute("data-file-candidate") + } + + const dispatch = (el: HTMLElement, p: string) => { + if (!data.validateFiles) return + void checkFile(props.message.sessionID, p, data.validateFiles).then((exists) => { + // `undefined` means validation could not be confirmed (e.g. every retry + // timed out) — leave the candidate untouched so a later pass can retry it + // instead of demoting a possibly-real file to plain text. + if (exists === undefined) return + // Guard against a stale response racing a newer candidate: morphdom can + // rewrite this same node in place during streaming (e.g. "src/fo" + // -> "src/foo.ts"), so only apply the result if the element is still + // mounted and still represents the path we validated. + if (!el.isConnected) return + if (!el.classList.contains("file-link-candidate")) return + if (el.getAttribute("data-file-candidate") !== p) return + promote(el, p, exists) + }) + } + + // The Markdown component writes its DOM asynchronously (rAF-coalesced + // morphdom), so scanning for candidates synchronously misses them. Drive + // validation from a MutationObserver, coalescing bursts into one pass per + // frame. Links are created *during* streaming, but a candidate is only probed + // once its path has settled: because morphdom keeps the same node as a + // streamed path grows (`src/fo` -> `src/foo.ts`), we track a per-element + // debounce and re-arm it whenever that element's path changes. Intermediate + // partials are superseded (their timer cleared) before they ever hit the + // filesystem, so a growing token costs one probe for its final value, not one + // per frame — while settled paths behind the streaming frontier light up + // without waiting for the whole message. On completion we flush immediately. + onMount(() => { + if (!bodyRef) return + const SETTLE_MS = 400 + const pending = new Map }>() + + // Arm (or flush) validation for one candidate. `immediate` skips the + // settle delay — used once the message is no longer streaming, so completed + // history and just-finished messages validate without a 400ms lag. + const arm = (el: HTMLElement, p: string, immediate: boolean) => { + const prior = pending.get(el) + if (immediate) { + if (prior) clearTimeout(prior.timer) + pending.delete(el) + dispatch(el, p) + return + } + // Already counting down for this exact path — don't reset, or a candidate + // behind the streaming frontier would never settle while others mutate. + if (prior && prior.path === p) return + if (prior) clearTimeout(prior.timer) + pending.set(el, { + path: p, + timer: setTimeout(() => { + pending.delete(el) + dispatch(el, p) + }, SETTLE_MS), + }) + } + + const scan = (immediate: boolean) => { + if (!bodyRef || !data.validateFiles) return + for (const el of bodyRef.querySelectorAll("code.file-link-candidate")) { + const p = el.getAttribute("data-file-candidate") ?? "" + if (p) arm(el, p, immediate) + } + } + + let scheduled = false + let frame: number | undefined + const schedule = () => { + if (scheduled) return + scheduled = true + frame = requestAnimationFrame(() => { + scheduled = false + frame = undefined + scan(!streaming()) + }) + } + // Ignore mutations promote() causes itself: a promoted/demoted node loses + // the file-link-candidate class, so an attribute-only change on a node + // that's no longer a candidate is our own write and must not schedule + // another (no-op) pass. Real new/changed candidates arrive via childList + // or characterData, or as attribute changes on still-candidate nodes. + const observer = new MutationObserver((records) => { + const relevant = records.some( + (r) => + r.type !== "attributes" || + (r.target instanceof HTMLElement && r.target.classList.contains("file-link-candidate")), + ) + if (relevant) schedule() + }) + observer.observe(bodyRef, { + attributes: true, + attributeFilter: ["class", "data-file-candidate", "data-file-line", "data-file-col"], + characterData: true, + childList: true, + subtree: true, + }) + schedule() + // When streaming stops, flush any still-pending candidate immediately (and + // validate on mount for completed history). Also rescues a candidate left + // "unknown" after a mid-render validation timeout once mutations stop. + createEffect(() => { + if (!streaming()) schedule() + }) + onCleanup(() => { + observer.disconnect() + if (frame !== undefined) cancelAnimationFrame(frame) + for (const entry of pending.values()) clearTimeout(entry.timer) + pending.clear() + }) + }) + const handleMarkdownClick = (e: MouseEvent) => { if (!data.openFile) return const target = e.target if (!(target instanceof HTMLElement)) return - // Handle .file-link code spans (e.g. `src/foo.ts:42`) + // Handle .file-link code spans (confirmed by filesystem validation) const fileLink = target.closest(".file-link[data-file-path]") if (fileLink) { const path = fileLink.getAttribute("data-file-path") @@ -1424,7 +1565,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const colAttr = fileLink.getAttribute("data-file-col") const line = lineAttr ? parseInt(lineAttr, 10) : undefined const column = colAttr ? parseInt(colAttr, 10) : undefined - data.openFile(path, line, column) + // Scope the open to the session this message was rendered for, matching + // how the candidate was validated — see checkFile / validateFiles. + data.openFile(path, line, column, props.message.sessionID) return } // Handle markdown links whose href looks like a relative file path @@ -1432,17 +1575,17 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { if (anchor) { const href = anchor.getAttribute("href") if (!href) return - const filePath = extractFilePathFromHref(href) - if (!filePath) return + const result = extractFilePathFromHref(href) + if (!result) return e.preventDefault() - data.openFile(filePath) + data.openFile(result.path, result.line, result.column, props.message.sessionID) } } return (
-
+
diff --git a/packages/kilo-ui/src/file-link-validator.test.ts b/packages/kilo-ui/src/file-link-validator.test.ts new file mode 100644 index 00000000000..91bc257122c --- /dev/null +++ b/packages/kilo-ui/src/file-link-validator.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test" +import { checkFile } from "./file-link-validator" + +// A fake validateFiles that records its calls and returns the given existing +// set. Paths not in `existing` are reported as missing. +function fake(existing: string[]) { + const calls: Array<{ sessionID: string; paths: string[] }> = [] + const fn = (sessionID: string, paths: string[]) => { + calls.push({ sessionID, paths }) + return Promise.resolve(paths.filter((p) => existing.includes(p))) + } + return { fn, calls } +} + +const tick = () => new Promise((r) => setTimeout(r, 0)) + +describe("checkFile", () => { + it("coalesces in-flight requests for the same path into one call", async () => { + const v = fake(["./a.ts"]) + const [r1, r2] = await Promise.all([checkFile("ses_dedup", "./a.ts", v.fn), checkFile("ses_dedup", "./a.ts", v.fn)]) + expect(r1).toBe(true) + expect(r2).toBe(true) + expect(v.calls.length).toBe(1) + }) + + it("batches distinct paths from the same tick into a single call", async () => { + const v = fake(["./a.ts"]) + const [a, b] = await Promise.all([checkFile("ses_batch", "./a.ts", v.fn), checkFile("ses_batch", "./b.ts", v.fn)]) + expect(a).toBe(true) + expect(b).toBe(false) + expect(v.calls.length).toBe(1) + expect(v.calls[0].paths.sort()).toEqual(["./a.ts", "./b.ts"]) + }) + + it("caches a confirmed positive result", async () => { + const v = fake(["./cached.ts"]) + expect(await checkFile("ses_pos", "./cached.ts", v.fn)).toBe(true) + expect(await checkFile("ses_pos", "./cached.ts", v.fn)).toBe(true) + expect(v.calls.length).toBe(1) + }) + + it("re-validates a negative result after its TTL expires", async () => { + const v = fake([]) + expect(await checkFile("ses_neg", "./missing.ts", v.fn, { negativeTtlMs: 10 })).toBe(false) + // Within TTL: served from cache, no new call. + expect(await checkFile("ses_neg", "./missing.ts", v.fn, { negativeTtlMs: 10 })).toBe(false) + expect(v.calls.length).toBe(1) + await new Promise((r) => setTimeout(r, 20)) + // After TTL: re-checked. + expect(await checkFile("ses_neg", "./missing.ts", v.fn, { negativeTtlMs: 10 })).toBe(false) + expect(v.calls.length).toBe(2) + }) + + it("resolves undefined (never false) when validation keeps failing", async () => { + const calls: number[] = [] + const rejecting = (_s: string, _p: string[]) => { + calls.push(1) + return Promise.reject(new Error("timeout")) + } + const result = await checkFile("ses_fail", "./slow.ts", rejecting, { maxAttempts: 2, retryDelayMs: 5 }) + expect(result).toBeUndefined() + expect(calls.length).toBe(2) // initial + one retry + + // A failure is not cached, so a later success still resolves true. + const v = fake(["./slow.ts"]) + await tick() + expect(await checkFile("ses_fail", "./slow.ts", v.fn)).toBe(true) + expect(v.calls.length).toBe(1) + }) +}) diff --git a/packages/kilo-ui/src/file-link-validator.ts b/packages/kilo-ui/src/file-link-validator.ts new file mode 100644 index 00000000000..e8e6a18cd72 --- /dev/null +++ b/packages/kilo-ui/src/file-link-validator.ts @@ -0,0 +1,179 @@ +// Shared file-link candidate validator used by message-part.tsx. +// +// This is intentionally a module-level singleton, not per-component state: +// - Virtualized transcripts unmount/remount TextPartDisplay instances as rows +// scroll in and out of the overscan buffer. A per-component cache would be +// discarded and rebuilt on every remount, repeating filesystem checks for +// unchanged completed history. A module-level cache survives remounts. +// - Multiple mounted rows can reference the same path concurrently (e.g. a +// file mentioned more than once in a transcript). Without shared in-flight +// tracking each row would issue its own redundant validateFiles request. +// +// Requests for the same session are coalesced into one batch per microtask +// tick, so many candidates validated back-to-back in the same animation +// frame (as MutationObserver-driven passes do) become a single round trip. +import type { ValidateFilesFn } from "./context/data" + +// Positives and negatives are cached separately, each LRU-bounded. Under the +// permissive code-span heuristic (looksLikeCandidate in file-path.ts) almost any +// bare token becomes a candidate, so a long session produces far more transient +// negatives (`useState`, `null`, …) than real files. Keeping them in their own +// map means that churn evicts only other negatives — a confirmed clickable file +// never gets pushed out by non-file probe noise — and eviction stays O(1). +const MAX_POSITIVE = 2000 +const MAX_NEGATIVE = 1000 + +export type CheckOptions = { + // How many times to re-attempt a rejected/timed-out validation before + // resolving `undefined` (never `false`). + maxAttempts: number + // Backoff between retry attempts, in ms. + retryDelayMs: number + // How long a confirmed "does not exist" stays cached, in ms. A path may be + // mentioned before its file is created during streaming, so negatives are + // re-checked after this window instead of staying plain for the whole + // session. Confirmed positives never expire. + negativeTtlMs: number +} + +const DEFAULTS: CheckOptions = { maxAttempts: 3, retryDelayMs: 2000, negativeTtlMs: 30_000 } + +// key: `${sessionID}::${path}`. Confirmed files never expire; a negative stores +// the timestamp (ms) at which it should be re-validated. Map iteration order is +// insertion order and reads refresh recency, so the oldest key is the LRU one. +const positives = new Set() +const negatives = new Map() +const inflight = new Map>() + +type Batch = { + paths: Set + resolvers: Map void>> + config: CheckOptions +} + +const batches = new Map() + +function key(sessionID: string, path: string): string { + return `${sessionID}::${path}` +} + +type Lru = { size: number; keys(): IterableIterator; delete(k: string): boolean } + +function evictOldest(map: Lru, max: number): void { + if (map.size < max) return + const oldest = map.keys().next().value + if (oldest !== undefined) map.delete(oldest) +} + +function remember(k: string, exists: boolean, negativeTtlMs: number): void { + // A path can flip negative→positive (a file created mid-session), so always + // clear the other tier. delete-then-set refreshes LRU recency for the key. + if (exists) { + negatives.delete(k) + positives.delete(k) + evictOldest(positives, MAX_POSITIVE) + positives.add(k) + return + } + negatives.delete(k) + evictOldest(negatives, MAX_NEGATIVE) + negatives.set(k, Date.now() + negativeTtlMs) +} + +function settle(batch: Batch, path: string, exists: boolean | undefined): void { + const resolvers = batch.resolvers.get(path) ?? [] + for (const resolve of resolvers) resolve(exists) +} + +function runBatch(sessionID: string, batch: Batch, validateFiles: ValidateFilesFn, attempt: number): void { + const paths = Array.from(batch.paths) + validateFiles(sessionID, paths).then( + (existing) => { + const set = new Set(existing) + for (const p of paths) { + const exists = set.has(p) + remember(key(sessionID, p), exists, batch.config.negativeTtlMs) + inflight.delete(key(sessionID, p)) + settle(batch, p, exists) + } + }, + () => { + // A timeout/failure is not authoritative — it must never be cached as + // "file doesn't exist". Retry a few times with a short backoff before + // giving up; even then, giving up resolves as `undefined` (unknown), + // not `false`, so the caller leaves the candidate untouched instead of + // demoting a real file to plain text. + if (attempt + 1 < batch.config.maxAttempts) { + setTimeout(() => runBatch(sessionID, batch, validateFiles, attempt + 1), batch.config.retryDelayMs) + return + } + for (const p of paths) { + inflight.delete(key(sessionID, p)) + settle(batch, p, undefined) + } + }, + ) +} + +/** + * Resolve whether `path` exists as a file, scoped to `sessionID`. + * + * Returns `true`/`false` once confirmed, or `undefined` if validation could + * not be confirmed (e.g. every retry timed out) — callers should treat + * `undefined` as "leave as-is", not as a negative result. + * + * `opts` tunes retry/TTL behavior. Note the config is per-batch: the first + * caller to open a per-tick batch for a session fixes the config for that + * batch, so callers batched into the same microtask tick share one config. + * Production callers omit `opts` and rely on DEFAULTS; `opts` exists for tests. + */ +export function checkFile( + sessionID: string, + path: string, + validateFiles: ValidateFilesFn, + opts?: Partial, +): Promise { + const k = key(sessionID, path) + if (positives.has(k)) { + // Refresh recency so LRU eviction keeps hot entries. + positives.delete(k) + positives.add(k) + return Promise.resolve(true) + } + const expires = negatives.get(k) + if (expires !== undefined) { + if (Date.now() < expires) { + negatives.delete(k) + negatives.set(k, expires) + return Promise.resolve(false) + } + negatives.delete(k) // expired negative → re-validate + } + const existing = inflight.get(k) + if (existing) return existing + + const batch: Batch = batches.get(sessionID) ?? { + paths: new Set(), + resolvers: new Map(), + config: { ...DEFAULTS, ...opts }, + } + const isNewBatch = !batches.has(sessionID) + batches.set(sessionID, batch) + batch.paths.add(path) + + const promise = new Promise((resolve) => { + const resolvers = batch.resolvers.get(path) ?? [] + resolvers.push(resolve) + batch.resolvers.set(path, resolvers) + }) + inflight.set(k, promise) + + if (isNewBatch) { + queueMicrotask(() => { + batches.delete(sessionID) + runBatch(sessionID, batch, validateFiles, 0) + }) + } + + return promise +} diff --git a/packages/kilo-ui/src/file-path.ts b/packages/kilo-ui/src/file-path.ts deleted file mode 100644 index e612dfd75ad..00000000000 --- a/packages/kilo-ui/src/file-path.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Matches text that looks like a file path: -// - Unix: /foo/bar.ts, ./foo.ts, ../foo.ts, foo.ts -// - Windows drive: C:\foo\bar.ts, C:/foo/bar.ts -// - Windows UNC: \\server\share\file.ts -// Supports optional :line or :line:col suffix. -const FILE_PATH_UNIX_RE = - /^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ -const FILE_PATH_WIN_RE = /^((?:[a-zA-Z]:[/\\]|\\\\)(?:[^\\/]+[/\\])*[^\\/]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ - -/** - * Parse an inline code span into a file path with optional line/column. - * Returns undefined when the text does not look like a file reference. - * - * Handles Unix paths (`/foo/bar.ts`, `./foo.ts`, `foo.ts`), - * Windows drive paths (`C:\foo\bar.ts`), and UNC paths (`\\server\share\file.ts`). - */ -export function parseFilePath(text: string): { path: string; line?: number; column?: number } | undefined { - if (text.includes("://")) return undefined - if (text.includes(" ")) return undefined - const match = FILE_PATH_UNIX_RE.exec(text) ?? FILE_PATH_WIN_RE.exec(text) - if (!match) return undefined - return { - path: match[1], - line: match[2] ? parseInt(match[2], 10) : undefined, - column: match[3] ? parseInt(match[3], 10) : undefined, - } -} - -const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ - -/** - * Extract a file path from a markdown link href, or return undefined - * when the href is a URL, anchor, scheme, or otherwise not a file reference. - * - * Strips `#fragment` and `?query` suffixes before returning the path. - */ -export function extractFilePathFromHref(href: string): string | undefined { - if (!href) return undefined - // Handle file:// URLs — extract the path component and decode it - if (href.startsWith("file://")) { - try { - const url = new URL(href) - const decoded = decodeURIComponent(url.pathname) - if (!decoded) return undefined - // On Windows, file:///C:/foo gives pathname=/C:/foo — strip the leading slash - // so the result is a valid Windows absolute path (C:/foo). - const c1 = decoded.charCodeAt(1) - const isWindowsDrive = - decoded.length >= 4 && - decoded.charCodeAt(0) === 47 /* / */ && - decoded.charCodeAt(2) === 58 /* : */ && - ((c1 >= 65 && c1 <= 90) /* A-Z */ || (c1 >= 97 && c1 <= 122)) /* a-z */ - return isWindowsDrive ? decoded.slice(1) : decoded - } catch { - return undefined - } - } - // Skip actual URLs and non-file schemes (mailto:, tel:, etc.) - if (href.includes("://") || SCHEME_RE.test(href)) return undefined - // Skip pure anchors - if (href.startsWith("#")) return undefined - // Strip fragment and query before treating as file path - const cleaned = href.replace(/[#?].*$/, "") - if (!cleaned) return undefined - // Must look like a file path (has a dot for extension) - if (!cleaned.includes(".")) return undefined - return cleaned -} diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 17cc3dde716..9d74bbbc04c 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,54 @@ # kilo-code +## 7.4.22 + +### Minor Changes + +- [#11219](https://github.com/Kilo-Org/kilocode/pull/11219) [`8013e5f`](https://github.com/Kilo-Org/kilocode/commit/8013e5f50451225bb32b1284579322c137f497e3) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Make file references in agent responses clickable by validating inline code spans against the filesystem. Code spans that match real files in the workspace become clickable links that open the file at the referenced line. Non-existent paths stay as plain code. Also adds fallback workspace search and "File not found" warning when clicking dead links. + +- [#13071](https://github.com/Kilo-Org/kilocode/pull/13071) [`3e3dd3d`](https://github.com/Kilo-Org/kilocode/commit/3e3dd3dcb2a711192ec2477ef7c0532b11f67886) Thanks [@cosi-conda](https://github.com/cosi-conda)! - Add Agent Manager PR comment actions: resolve/unresolve review threads, jump to comments section, and scroll-to-top for PR diff view. + +- [#13084](https://github.com/Kilo-Org/kilocode/pull/13084) [`5c97b48`](https://github.com/Kilo-Org/kilocode/commit/5c97b481d233d294859bcb737448661910fd4916) - Remove the built-in experimental Morph WarpGrep codebase search tool and ignore its retired configuration flag. + +### Patch Changes + +- [#13063](https://github.com/Kilo-Org/kilocode/pull/13063) [`ca9a99f`](https://github.com/Kilo-Org/kilocode/commit/ca9a99ffd8f118b5445e0fc2c890c4c6dd797d66) - Show and select the model's default reasoning variant in chat and Agent Manager. + +- [#13100](https://github.com/Kilo-Org/kilocode/pull/13100) [`753d560`](https://github.com/Kilo-Org/kilocode/commit/753d5609859f2b646c404392e71ca048714f61dd) - Support structured AWS access keys and Google Cloud service-account JSON when connecting Bedrock and Vertex AI in VS Code. + +- [#12373](https://github.com/Kilo-Org/kilocode/pull/12373) [`3a99f36`](https://github.com/Kilo-Org/kilocode/commit/3a99f36d96d316f03d481d7b120b9f1aaca243f1) Thanks [@mvanhorn](https://github.com/mvanhorn)! - Fix subagent permission errors that referenced phantom deny rules and blocked commands the subagent's own config explicitly allowed. A read-only or delegating agent's `readOnlyBash` allowlist is no longer projected onto a writable subagent as a bash ceiling, so a delegated subagent can run its own allowed commands (e.g. `git status`). Edit, notebook, and MCP denials are still inherited as hard ceilings. + +- [#13088](https://github.com/Kilo-Org/kilocode/pull/13088) [`9f4ab3a`](https://github.com/Kilo-Org/kilocode/commit/9f4ab3a8be8fedf23eb3950226dd92fcc9f0cc27) - Keep the Changes chip and Git changes visible across tab switches in multi-repository workspaces. + +- Changes from opencode v1.17.13 to v1.18.0 upstream: + - Core Improvements: Added a code mode MCP adapter for running confined orchestration scripts against connected MCP tools. + - Core Improvements: Hid the `execute` tool unless code mode is enabled. + - Core Improvements: Add a model-specific system prompt for Meta Muse Spark. + - Core Improvements: Updated Azure AI support for GPT-5.6. + - Core Bugfixes: Fixed paginated MCP tool catalogs losing tool metadata and output schema validation. + - Core Bugfixes: Preserved low reasoning effort for OpenRouter small-model variants instead of disabling it. + - Core Bugfixes: Fixed GitHub Copilot model routing to honor each model's advertised chat or responses endpoint. + - Core Bugfixes: Fixed session lists to match equivalent instance directories reliably. + - Core Bugfixes: Fixed Cerebras reasoning replay so earlier assistant reasoning is sent back in the provider-supported field. + - Core Bugfixes: Better classify Z.ai context-window overflow errors so oversized requests surface the right failure mode (@fengjikui) + - Core Bugfixes: Handle unavailable config directories more gracefully when reading config files + - Core Bugfixes: Exposed reasoning effort variants for Grok models. + - Core Bugfixes: Improved xAI prompt cache routing and PDF file support in Responses models. + - Core Bugfixes: Improved Meta model handling for reasoning variants and provider requests. + - Core Bugfixes: Prevent crashes and bad pricing data when GitHub Copilot returns models with a zero billing batch size. + - Core Bugfixes: Supported OpenAI pro reasoning mode. + - Core Bugfixes: Disabled response storage by default for xAI Responses. (@geraint0923) + - Core Bugfixes: Added OAuth support for Luna Responses Lite. + - Core Bugfixes: Switched to another available org after logging out in the console. + - Core Bugfixes: Used Codex context limits for GPT-5.6 over OAuth. (@nabilfreeman) + - Core Bugfixes: Removed an obsolete Codex workaround that could interfere with OpenAI Luna Responses Lite requests. + - TUI Bugfixes: Fixed spinner registration so loading indicators keep rendering across TUI surfaces. + - TUI Bugfixes: Forwarded CLI environment variables to the TUI worker. + +- Adopt upstream improvements from v1.18.1 through v1.18.13, including model compatibility, MCP reliability, and TUI enhancements. + +- [#13104](https://github.com/Kilo-Org/kilocode/pull/13104) [`9b01d97`](https://github.com/Kilo-Org/kilocode/commit/9b01d97cf336e42a33ede75d44232c907c022938) - Preserve workspace restoration outcomes when reverting fresh VS Code sessions. + ## 7.4.21 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index d1a01d843ea..402afa6eec1 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.4.21", + "version": "7.4.22", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 6483d455eea..144b7fdd613 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -57,6 +57,7 @@ import { resolveProjectDirectory } from "./project-directory" import { seedSessionStatuses } from "./session-status" import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error" import { retry } from "./services/cli-backend/retry" +import { removeAgent } from "./services/agent-removal" import { normalize, type SSEPayload, type SyncPayload, type WirePayload } from "./services/cli-backend/sdk-sse-adapter" import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" @@ -221,6 +222,7 @@ function sandboxClient(client: KiloClient | null) { const mapAgent = (a: Agent) => ({ name: a.name, displayName: a.displayName, + source: a.source, description: a.description, mode: a.mode, native: a.native, @@ -1572,7 +1574,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private handleEditorOpenMessage(message: Parameters[0]): boolean { return handleEditorAction(message, { - dir: () => this.getWorkspaceDirectory(this.currentSession?.id), + // An explicit sessionID (e.g. from validateFiles) takes precedence over + // the live currentSession — see editor-actions.ts's validateFiles case. + dir: (sessionID) => this.getWorkspaceDirectory(sessionID ?? this.currentSession?.id), diff: this.diffVirtualProvider, storage: this.extensionContext?.globalStorageUri, post: (msg) => this.postMessage(msg), @@ -2609,14 +2613,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Remove an agent via the CLI backend, then refresh. */ private async handleRemoveAgent(name: string): Promise { - if (!this.client) return - try { - const result = await this.client.kilocode.removeAgent({ name, directory: this.getWorkspaceDirectory() }) - if (result.error) { - console.error("[Kilo New] removeAgent returned error:", result.error) - } - } catch (err) { - console.error("[Kilo New] Failed to remove agent:", err) + const result = await removeAgent({ + connection: this.connectionService, + directory: this.getWorkspaceDirectory(), + name, + }) + if (!result.success) { + console.error("[Kilo New] Failed to remove agent:", result.error) + void vscode.window.showErrorMessage(result.error ?? `Failed to remove agent "${name}".`) } this.cachedAgentsMessage = null await this.fetchAndSendAgents() diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index d94a4511fc2..171baccec0a 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -5,8 +5,8 @@ import { execWithShellEnv } from "./shell-env" import { execGhRead } from "./gh" import { classifyPRError } from "./git-import" import type { Semaphore } from "./semaphore" -import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./am-pr-utils" -import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" +import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./pr/am-pr-utils" +import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./pr/am-pr-types" interface PRStatusPollerOptions { getWorktrees: () => Worktree[] @@ -485,7 +485,9 @@ export class PRStatusPoller { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { reviewThreads(first: 100) { + totalCount nodes { + id isResolved comments(first: 1) { nodes { @@ -496,6 +498,7 @@ export class PRStatusPoller { line url createdAt + diffHunk } } } @@ -520,8 +523,10 @@ export class PRStatusPoller { { cwd, timeout: 15_000 }, ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest - const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[]) - return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments } + const threads = pr?.reviewThreads + const comments = parseComments((threads?.nodes ?? []) as GhThread[]) + const totalCount = threads?.totalCount ?? comments.length + return { total: totalCount, unresolved: comments.filter((c) => !c.resolved).length, comments } } catch (err) { this.options.log("Failed to fetch PR comments:", err) return { total: 0, unresolved: 0, comments: [] } diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index 42370a5e721..40328a63c26 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -9,6 +9,7 @@ import type { AgentManagerOutMessage, PRStatus } from "./types" import type { Disposable } from "./host" import type { Semaphore } from "./semaphore" import { PRStatusPoller } from "./PRStatusPoller" +import { resolveComment, unresolveComment } from "./pr/PRActions" interface PRBridgeHost { getWorktrees(): Worktree[] @@ -85,6 +86,49 @@ export class PRStatusBridge { if (url) this.host.openExternal(url) return true } + const isResolve = m.type === "agentManager.resolveComment" + const isUnresolve = m.type === "agentManager.unresolveComment" + if (isResolve || isUnresolve) { + const id = m.worktreeId as string + const threadId = m.threadId as string + const wt = this.host.getWorktrees().find((w: Worktree) => w.id === id) + const cwd = wt?.path ?? this.host.getWorkspaceRoot() + const resultType = isResolve ? "agentManager.resolveCommentResult" : "agentManager.unresolveCommentResult" + if (!cwd) { + this.host.log("resolveComment: no cwd for worktree", id) + this.host.postToWebview({ + type: resultType, + worktreeId: id, + threadId, + success: false, + }) + return true + } + const action = isResolve ? resolveComment : unresolveComment + action(threadId, cwd).then( + () => { + this.host.postToWebview({ + type: resultType, + worktreeId: id, + threadId, + success: true, + }) + // Refresh PR data after successful mutation to get updated comment state + this.poller.refresh(id) + }, + (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + this.host.log(`${resultType} failed: ${msg}`) + this.host.postToWebview({ + type: resultType, + worktreeId: id, + threadId, + success: false, + }) + }, + ) + return true + } return false } diff --git a/packages/kilo-vscode/src/agent-manager/pr/PRActions.ts b/packages/kilo-vscode/src/agent-manager/pr/PRActions.ts new file mode 100644 index 00000000000..3dd92b9ff7c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pr/PRActions.ts @@ -0,0 +1,30 @@ +import { execGhRead } from "../gh" +import { GH_MUTATION_TIMEOUT } from "./pr-constants" + +export async function resolveComment(threadId: string, cwd: string): Promise { + const mutation = `mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }` + try { + await execGhRead(["api", "graphql", "-f", `query=${mutation}`, "-F", `id=${threadId}`], { + cwd, + timeout: GH_MUTATION_TIMEOUT, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const stderr = (err as Record).stderr + throw new Error(`Could not resolve thread: ${msg}${stderr ? ` — ${stderr}` : ""}`) + } +} + +export async function unresolveComment(threadId: string, cwd: string): Promise { + const mutation = `mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }` + try { + await execGhRead(["api", "graphql", "-f", `query=${mutation}`, "-F", `id=${threadId}`], { + cwd, + timeout: GH_MUTATION_TIMEOUT, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const stderr = (err as Record).stderr + throw new Error(`Could not unresolve thread: ${msg}${stderr ? ` — ${stderr}` : ""}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts b/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts similarity index 88% rename from packages/kilo-vscode/src/agent-manager/am-pr-types.ts rename to packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts index 4cd818d7a09..506e0c727ab 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts @@ -1,4 +1,4 @@ -import type { PRState, ReviewDecision } from "./types" +import type { PRState, ReviewDecision } from "../types" // Raw shapes returned by `gh pr view --json` @@ -14,8 +14,10 @@ export interface GhComment { line?: number url?: string createdAt?: string + diffHunk?: string } export interface GhThread { + id?: string isResolved?: boolean comments?: { nodes?: GhComment[] } } diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts similarity index 97% rename from packages/kilo-vscode/src/agent-manager/am-pr-utils.ts rename to packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts index 5fe6e0fad5a..f0521a1a613 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts @@ -1,4 +1,4 @@ -import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "./types" +import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "../types" import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" export function parsePRResult(json: string): PRResult | null { @@ -73,6 +73,7 @@ export function parseComments(threads: GhThread[]): PRComment[] { if (!first) continue items.push({ id: first.id, + threadId: thread.id ?? first.id, author: first.author?.login ?? "unknown", avatar: first.author?.avatarUrl, body: first.body ?? "", @@ -81,6 +82,7 @@ export function parseComments(threads: GhThread[]): PRComment[] { url: first.url, resolved: thread.isResolved ?? false, createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, + diffHunk: first.diffHunk, }) } return items diff --git a/packages/kilo-vscode/src/agent-manager/pr/pr-constants.ts b/packages/kilo-vscode/src/agent-manager/pr/pr-constants.ts new file mode 100644 index 00000000000..813a99b26ad --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pr/pr-constants.ts @@ -0,0 +1,2 @@ +// Timeouts for gh CLI and GraphQL calls in PR actions +export const GH_MUTATION_TIMEOUT = 15_000 // 15 seconds — gh api graphql mutations diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 01c88ff1581..1d76a49abfe 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -61,6 +61,7 @@ export interface PRCheck { export interface PRComment { id: string + threadId: string author: string avatar?: string body: string @@ -69,6 +70,7 @@ export interface PRComment { url?: string resolved: boolean createdAt?: number + diffHunk?: string } export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" @@ -394,6 +396,14 @@ interface PRErrorOutMessage { error: "gh_missing" | "gh_auth" | "fetch_failed" } +interface CommentActionResultMessage { + type: "agentManager.resolveCommentResult" | "agentManager.unresolveCommentResult" + worktreeId: string + threadId: string + success: boolean + error?: string +} + interface ActionOutMessage { type: "action" action: string @@ -434,6 +444,7 @@ export type AgentManagerOutMessage = | DiffBranchesMessage | PRStatusOutMessage | PRErrorOutMessage + | CommentActionResultMessage | ActionOutMessage | RunStatusMessage | TerminalCreatedMessage @@ -758,6 +769,12 @@ interface OpenPRIn { url?: string } +interface CommentActionIn { + type: "agentManager.resolveComment" | "agentManager.unresolveComment" + worktreeId: string + threadId: string +} + interface OpenSessionsIn { type: "agentManager.openSessions" sessionIDs: string[] @@ -1050,6 +1067,7 @@ export type AgentManagerInMessage = | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn + | CommentActionIn | OpenSessionsIn | VisibleSessionIn | OpenFileIn diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts index c92ede42873..982c587045a 100644 --- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -11,6 +11,7 @@ type EditorOpenMessage = { column?: number content?: string language?: string + sessionID?: string } function openExternal(url: unknown): void { @@ -73,14 +74,17 @@ export function handleEditorAction( paths?: string[] }, opts: { - dir: () => string + dir: (sessionID?: string) => string diff?: DiffVirtualProvider storage?: vscode.Uri post?: (msg: unknown) => void }, ): boolean { if (message.type === "openFile") { - if (message.filePath) openFile(opts.dir(), message.filePath, message.line, message.column) + // Resolve the directory from the session the file reference was rendered + // for (when the webview provides it), not whatever session happens to be + // current — mirrors the validateFiles case below. + if (message.filePath) openFile(opts.dir(message.sessionID), message.filePath, message.line, message.column) return true } if (message.type === "openContent") { @@ -92,7 +96,11 @@ export function handleEditorAction( const paths = message.paths if (id && paths && opts.post) { const post = opts.post - validateFiles(opts.dir(), paths).then( + // Resolve the directory from the session id the webview validated the + // candidates against, not whatever session happens to be current when + // this message is processed (avoids validating against the wrong + // worktree during an Agent Manager session switch). + validateFiles(opts.dir(message.sessionID), paths).then( (existing) => post({ type: "validateFilesResult", id, existing }), (err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err), ) diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts index d038005bea3..e96a124cf39 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-links.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts @@ -3,6 +3,23 @@ import * as path from "node:path" import * as vscode from "vscode" import { contains } from "../path-utils" +// Streaming responses can turn most inline code spans into candidates, so a +// single message can carry hundreds of paths. Running them all through +// realpath+stat at once can overwhelm a slow or remote filesystem; cap how +// many run concurrently instead of firing an unbounded Promise.all. +const CONCURRENCY = 8 + +function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results: R[] = new Array(items.length) + const iter = items.entries() + const worker = async (): Promise => { + for (const [index, item] of iter) { + results[index] = await fn(item) + } + } + return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results) +} + /** * Stat-check candidate paths and return which ones are actual files (not directories). * @@ -33,7 +50,7 @@ export function validateFiles(root: string, paths: string[]): Promise () => null, ) } - return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null)) + return mapLimit(paths, CONCURRENCY, check).then((r) => r.filter((x): x is string => x !== null)) }, () => [], ) diff --git a/packages/kilo-vscode/src/services/agent-removal.ts b/packages/kilo-vscode/src/services/agent-removal.ts new file mode 100644 index 00000000000..df9f4583f15 --- /dev/null +++ b/packages/kilo-vscode/src/services/agent-removal.ts @@ -0,0 +1,34 @@ +import { getErrorMessage } from "../kilo-provider-utils" +import type { KiloConnectionService } from "./cli-backend" +import type { RemoveResult } from "./marketplace/types" + +interface Input { + connection: KiloConnectionService + directory: string + name: string + scope?: "project" | "global" +} + +export async function removeAgent(input: Input): Promise { + try { + const client = await input.connection.getClientAsync(input.directory) + const result = await client.kilocode.removeAgent({ + name: input.name, + directory: input.directory, + scope: input.scope, + }) + if (!result.error) return { success: true, slug: input.name } + + return { + success: false, + slug: input.name, + error: getErrorMessage(result.error) || `Agent "${input.name}" is still provided by another configuration.`, + } + } catch (err) { + return { + success: false, + slug: input.name, + error: getErrorMessage(err) || `Failed to remove agent "${input.name}".`, + } + } +} diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts index 9237d54ecac..006043193a0 100644 --- a/packages/kilo-vscode/src/services/marketplace/actions.ts +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -2,6 +2,7 @@ import * as path from "path" import * as vscode from "vscode" import type { KiloConnectionService } from "../cli-backend" import { retry } from "../cli-backend/retry" +import { removeAgent } from "../agent-removal" import type { MarketplaceService } from "." import type { InstallMarketplaceItemOptions, @@ -67,6 +68,12 @@ export async function removeMarketplaceItem( } try { + if (item.type === "agent") { + const target = scope === "project" ? project! : dir + const result = await removeAgent({ connection: ctx.connection, directory: target, name: item.id, scope }) + if (result.success) await invalidate(ctx, scope, target) + return result + } if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, scope) const result = await ctx.marketplace.remove(item, scope, project) if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir) diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index cdec7ddf5d9..8ef1cf455e0 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.21", + "version": "7.4.22", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts index 796a2d0eebc..60dbea402b0 100644 --- a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -2,12 +2,22 @@ import { describe, expect, it } from "bun:test" import { mcpConfigScope, mcpEnabledPatch, + removable, selectedAgentNumberOverrideValue, selectedAgentTextOverrideValue, selectedDefaultAgentValue, shouldClearDefaultAgentWhenAgentBecomesUnavailable, } from "../../webview-ui/src/components/settings/agent-behaviour-patches" +describe("removable", () => { + it("only allows user-managed custom agents", () => { + expect(removable({ name: "reviewer", mode: "primary", native: false })).toBe(true) + expect(removable({ name: "code", mode: "primary", native: true })).toBe(false) + expect(removable({ name: "managed", mode: "primary", source: "organization" })).toBe(false) + expect(removable(undefined)).toBe(false) + }) +}) + describe("mcpEnabledPatch", () => { it("returns only the enabled-state patch", () => { expect(mcpEnabledPatch("docs", false)).toEqual({ diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts index 734b3128f6e..0b03bfd8379 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, mock, beforeEach } from "bun:test" + +const resolveComment = mock(async (_threadId: string, _cwd: string) => {}) +const unresolveComment = mock(async (_threadId: string, _cwd: string) => {}) + +mock.module("../../src/agent-manager/pr/PRActions", () => ({ resolveComment, unresolveComment })) + import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge" import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types" @@ -9,6 +15,7 @@ const pr: PRStatus = { state: "open", review: null, checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, + reviewers: [], additions: 0, deletions: 0, files: 0, @@ -16,7 +23,7 @@ const pr: PRStatus = { function harness(opts: { hasPersisted?: boolean } = {}) { const sent: AgentManagerOutMessage[] = [] - const worktrees: { id: string; prUrl?: string }[] = [] + const worktrees: { id: string; path: string; prUrl?: string }[] = [{ id: "wt1", path: "/repo/wt1" }] const bridge = PRStatusBridge.create({ getWorktrees: () => worktrees as never, getWorkspaceRoot: () => "/repo", @@ -186,3 +193,78 @@ describe("PRStatusBridge.reset", () => { expect(sent).toHaveLength(1) }) }) + +// --- resolveComment / unresolveComment message handling --- + +describe("PRStatusBridge.handleMessage resolveComment", () => { + beforeEach(() => { + resolveComment.mockReset() + unresolveComment.mockReset() + }) + + it("returns true for agentManager.resolveComment", () => { + const { bridge } = harness() + resolveComment.mockResolvedValueOnce(undefined) + expect(bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" })).toBe( + true, + ) + }) + + it("returns true for agentManager.unresolveComment", () => { + const { bridge } = harness() + unresolveComment.mockResolvedValueOnce(undefined) + expect(bridge.handleMessage({ type: "agentManager.unresolveComment", worktreeId: "wt1", threadId: "PRT_1" })).toBe( + true, + ) + }) + + it("posts resolveCommentResult with success:true on resolve success", async () => { + const { bridge, sent } = harness() + resolveComment.mockResolvedValueOnce(undefined) + bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" }) + await Promise.resolve() + const result = sent.find((m) => m.type === "agentManager.resolveCommentResult") + expect(result).toEqual( + expect.objectContaining({ + type: "agentManager.resolveCommentResult", + worktreeId: "wt1", + threadId: "PRT_1", + success: true, + }), + ) + }) + + it("posts unresolveCommentResult with success:true on unresolve success", async () => { + const { bridge, sent } = harness() + unresolveComment.mockResolvedValueOnce(undefined) + bridge.handleMessage({ type: "agentManager.unresolveComment", worktreeId: "wt1", threadId: "PRT_1" }) + await Promise.resolve() + const result = sent.find((m) => m.type === "agentManager.unresolveCommentResult") + expect(result).toEqual(expect.objectContaining({ success: true })) + }) + + it("posts resolveCommentResult with success:false on failure", async () => { + const { bridge, sent } = harness() + resolveComment.mockRejectedValueOnce(new Error("gh: Not Found")) + bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" }) + await Promise.resolve() + const result = sent.find((m) => m.type === "agentManager.resolveCommentResult") + expect(result).toEqual(expect.objectContaining({ success: false })) + }) + + it("logs and returns early when no cwd found", () => { + const logged: unknown[] = [] + const bridge = PRStatusBridge.create({ + getWorktrees: () => [] as never, + getWorkspaceRoot: () => undefined, + postToWebview: () => {}, + updateWorktreePR: () => {}, + hasPersistedPR: () => false, + openExternal: () => {}, + log: (...args) => logged.push(args), + }) + bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt-missing", threadId: "PRT_1" }) + expect(resolveComment).not.toHaveBeenCalled() + expect(logged.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts index ba2d8d80070..21bf23b56c2 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts @@ -5,8 +5,8 @@ import { formatCheckDuration, parseComments, parseReviewers, -} from "../../src/agent-manager/am-pr-utils" -import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types" +} from "../../src/agent-manager/pr/am-pr-utils" +import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/pr/am-pr-types" // --- parsePRResult --- @@ -220,6 +220,7 @@ describe("parseComments", () => { it("parses a resolved thread", () => { const threads: GhThread[] = [ { + id: "PRT_thread1", isResolved: true, comments: { nodes: [ @@ -239,6 +240,7 @@ describe("parseComments", () => { expect(parseComments(threads)).toEqual([ { id: "c1", + threadId: "PRT_thread1", author: "alice", avatar: "https://avatar", body: "looks good", @@ -247,10 +249,30 @@ describe("parseComments", () => { url: "https://url", resolved: true, createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + diffHunk: undefined, }, ]) }) + it("uses comment id as threadId fallback when thread has no id", () => { + const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }] + const result = parseComments(threads) + expect(result[0]?.threadId).toBe("c2") + }) + + it("parses diffHunk when present", () => { + const threads: GhThread[] = [ + { + id: "PRT_t1", + isResolved: false, + comments: { + nodes: [{ id: "c3", body: "fix this", diffHunk: "@@ -1,3 +1,4 @@\n context\n+new line" }], + }, + }, + ] + expect(parseComments(threads)[0]?.diffHunk).toBe("@@ -1,3 +1,4 @@\n context\n+new line") + }) + it("defaults missing author to 'unknown'", () => { const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }] expect(parseComments(threads)[0]?.author).toBe("unknown") @@ -259,6 +281,7 @@ describe("parseComments", () => { it("only uses the first comment of each thread", () => { const threads: GhThread[] = [ { + id: "PRT_t2", isResolved: false, comments: { nodes: [ diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index fc47330c300..92f7045f00c 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -29,6 +29,14 @@ const item: McpMarketplaceItem = { url: "", content: "", } +const agent = { + id: "reviewer", + type: "agent" as const, + name: "Code Reviewer", + description: "", + category: "development", + content: { mode: "all" as const, description: "Reviews code", prompt: "Review code" }, +} const fs = vscode.workspace.fs as unknown as { readFile: (uri: vscode.Uri) => Promise writeFile: (uri: vscode.Uri, data: Uint8Array) => Promise @@ -191,3 +199,61 @@ describe("Marketplace legacy MCP cleanup", () => { expect(has(files, global)).toBe(false) }) }) + +describe("Marketplace agent removal", () => { + it("uses the authoritative CLI removal and invalidates the resolved directory", async () => { + const remove = mock(async () => ({ data: true })) + const dispose = mock(async () => ({})) + const getClientAsync = mock(async () => ({ + kilocode: { removeAgent: remove }, + global: { config: { update: mock(async () => ({})) } }, + instance: { dispose }, + })) + const marketplace = { remove: mock(async () => ({ success: true, slug: agent.id })) } + const ctx = { connection: { getClientAsync }, marketplace } as unknown as MarketplaceActionContext + + const result = await removeMarketplaceItem(ctx, agent, "global", project, project) + + expect(result).toEqual({ success: true, slug: agent.id }) + expect(remove).toHaveBeenCalledWith({ name: agent.id, directory: project, scope: "global" }) + expect(marketplace.remove).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledWith({ directory: project }) + }) + + it("returns a failure when the authoritative removal rejects the agent", async () => { + const getClientAsync = mock(async () => ({ + kilocode: { removeAgent: mock(async () => ({ error: { message: "Agent is still configured" } })) }, + instance: { dispose: mock(async () => ({})) }, + })) + const ctx = { + connection: { getClientAsync }, + marketplace: { remove: mock(async () => ({ success: true, slug: agent.id })) }, + } as unknown as MarketplaceActionContext + + const result = await removeMarketplaceItem(ctx, agent, "project", project, project) + + expect(result).toEqual({ success: false, slug: agent.id, error: "Agent is still configured" }) + }) + + it("uses friendly fallbacks for empty backend errors", async () => { + const remove = mock(async () => ({ error: new Error("") })) + const getClientAsync = mock(async () => ({ kilocode: { removeAgent: remove } })) + const ctx = { + connection: { getClientAsync }, + marketplace: { remove: mock(async () => ({ success: true, slug: agent.id })) }, + } as unknown as MarketplaceActionContext + + const rejected = await removeMarketplaceItem(ctx, agent, "project", project, project) + expect(rejected).toEqual({ + success: false, + slug: agent.id, + error: `Agent "${agent.id}" is still provided by another configuration.`, + }) + + getClientAsync.mockImplementation(async () => { + throw new Error("") + }) + const failed = await removeMarketplaceItem(ctx, agent, "global", project, project) + expect(failed).toEqual({ success: false, slug: agent.id, error: `Failed to remove agent "${agent.id}".` }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/memory-command.test.ts b/packages/kilo-vscode/tests/unit/memory-command.test.ts index d42a67aa866..3203a4dcc4a 100644 --- a/packages/kilo-vscode/tests/unit/memory-command.test.ts +++ b/packages/kilo-vscode/tests/unit/memory-command.test.ts @@ -22,6 +22,7 @@ type Case = { text?: string query?: string reason?: string + rest?: string } const cases = (await Bun.file( @@ -31,7 +32,7 @@ const cases = (await Bun.file( function expected(item: Case): ParsedMemoryCommand | undefined { if (item.result === "none") return if (item.result === "help") return { kind: "help" } - if (item.result === "show") return { kind: "show" } + if (item.result === "show") return { kind: "show", rest: item.rest ?? "" } if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" } if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`) if (item.operation === "remember" || item.operation === "correct") { @@ -50,7 +51,7 @@ function expected(item: Case): ParsedMemoryCommand | undefined { if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`) return { kind: "operation", operation: item.operation, confirm: true } } - return { kind: "operation", operation: item.operation } + return { kind: "operation", operation: item.operation, rest: item.rest ?? "" } } describe("parseMemoryCommand", () => { diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index fbbcab51ad8..3dc1cac87f3 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -12,7 +12,9 @@ import { isPathMention, applySandboxState, applySandboxStates, + memoryRest, } from "../../webview-ui/src/components/chat/prompt-input-utils" +import { parseMemoryCommand } from "../../webview-ui/src/utils/memory-command" describe("applySandboxState", () => { const state = (enabled: boolean, revision: number, sessionID = "ses_1", directory = "/repo") => ({ @@ -329,3 +331,33 @@ describe("isPathMention", () => { expect(isPathMention("src/foo.ts")).toBe(true) }) }) + +describe("memoryRest", () => { + it("keeps trailing text in the input after a no-argument memory command", () => { + // /memory rebuild hello -> rebuild executes, "hello" stays in the input. + // This is the submit-path half of the trailing-text bug: handleSend sets + // the input to memoryRest(parsed), so a regression would drop "hello". + const memory = parseMemoryCommand("/memory rebuild hello") + expect(memory).not.toBeUndefined() + expect(memoryRest(memory!)).toBe("hello") + }) + + it("keeps trailing text through the project scope", () => { + expect(memoryRest(parseMemoryCommand("/memory project rebuild hello")!)).toBe("hello") + }) + + it("returns empty string when a no-argument command has no trailing text", () => { + expect(memoryRest(parseMemoryCommand("/memory rebuild")!)).toBe("") + }) + + it("keeps trailing text in the input after the show command", () => { + // /memory show draft notes -> show executes, "draft notes" stays in the input. + expect(memoryRest(parseMemoryCommand("/memory show draft notes")!)).toBe("draft notes") + }) + + it("returns empty string for argument-taking operations", () => { + // remember/correct/forget/auto/purge consume their text, so nothing remains. + expect(memoryRest(parseMemoryCommand("/memory remember hello")!)).toBe("") + expect(memoryRest(parseMemoryCommand("/memory auto on")!)).toBe("") + }) +}) 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 f94736a9603..cc44d15c8d0 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -276,6 +276,19 @@ describe("sendMessage / sendCommand draft id contract", () => { ) }) + it("sendMessage and sendCommand post the agent returned by promptAgent", () => { + expect(extractFunctionBody(source, "sendMessage")).toContain("const agent = promptAgent(scope)") + expect(extractFunctionBody(source, "sendCommand")).toContain("const agent = promptAgent(scope)") + expect(extractFunctionBody(source, "promptAgent")).toContain("return resolvePromptAgent({") + }) + + it("createSession and clearCurrentSession do not pin the provisional default agent", () => { + expect(extractFunctionBody(source, "createSession")).toContain("setPendingAgentSelection(null)") + expect(extractFunctionBody(source, "createSession")).not.toContain("setPendingAgentSelection(defaultAgent())") + expect(extractFunctionBody(source, "clearCurrentSession")).toContain("setPendingAgentSelection(null)") + expect(extractFunctionBody(source, "clearCurrentSession")).not.toContain("setPendingAgentSelection(defaultAgent())") + }) + it("does not clear a newer pending agent when a seeded draft is promoted", () => { const body = extractFunctionBody(source, "handleSessionCreated") const draftBlock = body.match(/if \(draftID\) \{([\s\S]*?)\} else if/) diff --git a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts index 8fc4c36b38b..c28477b20f7 100644 --- a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts +++ b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts @@ -23,6 +23,13 @@ function method(name: string, next: string) { return provider.slice(start, end) } +function exported(name: string) { + const start = sdk.indexOf(`export type ${name} = {`) + const end = sdk.indexOf("\nexport type ", start + 1) + expect(start).toBeGreaterThan(-1) + return sdk.slice(start, end === -1 ? undefined : end) +} + describe("message revert checkpoints", () => { it("keeps revert actions available after a session is already reverted", () => { expect(src).toMatch(/onRevert=\{\s*assistantMessages\(\)\.length > 0\s*\? \(\) =>/) @@ -59,11 +66,9 @@ describe("revert session synchronization", () => { describe("revert workspace restoration status", () => { it("renders explicit conversation-only outcomes", () => { expect(session).toContain('workspace?: "restored" | "snapshots-disabled" | "unavailable"') - expect(sdk).toMatch( - /export type Session = \{[\s\S]*?workspace\?: "restored" \| "snapshots-disabled" \| "unavailable"/, - ) - expect(sdk).toMatch( - /export type KilocodeSessionImportSessionData = \{[\s\S]*?workspace\?: "restored" \| "snapshots-disabled" \| "unavailable"/, + expect(exported("Session")).toContain('workspace?: "restored" | "snapshots-disabled" | "unavailable"') + expect(exported("KilocodeSessionImportSessionData")).toContain( + 'workspace?: "restored" | "snapshots-disabled" | "unavailable"', ) expect(banner).toContain('"revert.banner.workspace.snapshotsDisabled"') expect(banner).toContain('"revert.banner.workspace.unavailable"') diff --git a/packages/kilo-vscode/tests/unit/session-agent.test.ts b/packages/kilo-vscode/tests/unit/session-agent.test.ts index a6da4e4e177..a10cdbbe43c 100644 --- a/packages/kilo-vscode/tests/unit/session-agent.test.ts +++ b/packages/kilo-vscode/tests/unit/session-agent.test.ts @@ -3,6 +3,7 @@ import { cycleAgent, createDraftAgentSeed, draftAgentSelection, + resolvePromptAgent, resolveSessionAgent, } from "../../webview-ui/src/context/session-agent" import type { Message } from "../../webview-ui/src/types/messages" @@ -130,6 +131,27 @@ describe("cycleAgent", () => { }) }) +describe("resolvePromptAgent", () => { + it("sends Code after an explicit Ask to Code selection", () => { + expect( + resolvePromptAgent({ + sessionID: "ses_1", + selections: { ses_1: "code" }, + pending: "ask", + }), + ).toBe("code") + }) + + it("sends an explicit pending Code selection on a new draft", () => { + expect(resolvePromptAgent({ selections: {}, pending: "code" })).toBe("code") + }) + + it("omits the agent when there is no explicit selection", () => { + expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: null })).toBeUndefined() + expect(resolvePromptAgent({ selections: {}, pending: null })).toBeUndefined() + }) +}) + describe("draftAgentSelection", () => { it("carries a pending agent into a new draft scope", () => { const result = draftAgentSelection({}, "draft-1", "plan") diff --git a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts index 5294c21f8ad..e9410a49d6c 100644 --- a/packages/kilo-vscode/tests/unit/session-variant-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variant-store.test.ts @@ -52,6 +52,16 @@ describe("per-session variant selection", () => { expect(getAgentVariant(store, model, { variants: { low: {}, high: {} } }, "ask")).toBe("high") }) + it("uses the model default when no variant is selected", () => { + expect(getVariant({}, model, variants, "code")).toBeUndefined() + expect(getVariant({ [variantKey(model, "code")]: "" }, model, variants, "code")).toBeUndefined() + }) + + it("preserves a provider variant named default", () => { + const store = { [variantKey(model, "code")]: "default" } + expect(getVariant(store, model, ["default", "thinking"], "code")).toBe("default") + }) + it("carries the pre-submit agent variant into a newly created session", () => { const store: Record = {} @@ -109,8 +119,8 @@ describe("cycleVariant", () => { expect(cycleVariant("medium", variants)).toBe("high") }) - it("wraps back to the first variant after the last", () => { - expect(cycleVariant("high", variants)).toBe("low") + it("returns to the model default after the last variant", () => { + expect(cycleVariant("high", variants)).toBeUndefined() }) it("starts at the first variant when current is missing or unknown", () => { diff --git a/packages/kilo-vscode/tests/unit/session-variants.test.ts b/packages/kilo-vscode/tests/unit/session-variants.test.ts index c266da63105..97c04523f59 100644 --- a/packages/kilo-vscode/tests/unit/session-variants.test.ts +++ b/packages/kilo-vscode/tests/unit/session-variants.test.ts @@ -63,4 +63,18 @@ describe("session variants", () => { expect(scoped.selections).toEqual({ "session/session-a/anthropic/claude-sonnet-4": "low" }) expect(scoped.messages).toEqual([]) }) + + it("persists an explicit default selection", () => { + const state = setup() + state.variants.select(undefined) + expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" }) + expect(state.variants.current()).toBeUndefined() + expect(state.messages).toEqual([{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "" }]) + }) + + it("carries the model default across model changes", () => { + const state = setup() + state.variants.carry(model, undefined, "code") + expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" }) + }) }) 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 2c8b5f39d29..27934f6607a 100644 --- a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -60,14 +60,14 @@ describe("useSlashCommand sandbox action", () => { it("opens project memory actions from the top-level command", () => { const ctx = setup(() => {}) - const state = { text: "/memory" } + const state = { text: "/mem" } const textarea = { value: state.text, setSelectionRange: () => {}, focus: () => {}, } as unknown as HTMLTextAreaElement - ctx.slash.onInput("/mem", 4) + ctx.slash.onInput(state.text, state.text.length) expect(ctx.slash.results()).toContainEqual( expect.objectContaining({ name: "memory", description: "Manage project memory", hints: ["mem"] }), @@ -131,7 +131,11 @@ describe("useSlashCommand sandbox action", () => { it("runs the sandbox toggle as a client command", () => { const state = { toggles: 0, text: "/sandbox", prevented: 0 } const ctx = setup(() => state.toggles++) - const textarea = { value: state.text } as HTMLTextAreaElement + const textarea = { + value: state.text, + selectionStart: state.text.length, + setSelectionRange: () => {}, + } as unknown as HTMLTextAreaElement const event = { key: "Enter", isComposing: false, @@ -153,7 +157,11 @@ describe("useSlashCommand sandbox action", () => { it("keeps the command text when the sandbox control is disabled", () => { const state = { toggles: 0, text: "/sandbox" } const ctx = setup(() => state.toggles++, { enabled: () => false }) - const textarea = { value: state.text } as HTMLTextAreaElement + const textarea = { + value: state.text, + selectionStart: state.text.length, + setSelectionRange: () => {}, + } as unknown as HTMLTextAreaElement const event = { key: "Enter", isComposing: false, @@ -198,7 +206,7 @@ describe("useSlashCommand sandbox action", () => { focus: () => {}, } as unknown as HTMLTextAreaElement - ctx.slash.onInput("/rev", 4) + ctx.slash.onInput(state.text, state.text.length) expect(ctx.slash.results()).toContainEqual( expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }), @@ -261,3 +269,223 @@ describe("useSlashCommand sandbox action", () => { ctx.dispose() }) }) + +describe("select", () => { + it("preserves trailing text for action commands", () => { + let actionCalls = 0 + let currentText = "existing text" + const ctx = setup(() => {}) + + const textarea = { + value: "/newexisting text", + selectionStart: 4, + setSelectionRange: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + ctx.slash.select( + { + name: "new", + description: "Start a new session", + hints: [], + action: () => { + actionCalls++ + }, + }, + textarea, + setText, + ) + + expect(textarea.value).toBe("existing text") + expect(currentText).toBe("existing text") + expect(actionCalls).toBe(1) + ctx.dispose() + }) + + it("preserves trailing text for server commands and sets cursor", () => { + const ctx = setup(() => {}) + let currentText = "" + let selectionStart = 0 + + const textarea = { + value: "/docmdexisting text", + selectionStart: 6, + setSelectionRange: (start: number, end: number) => { + selectionStart = start + }, + focus: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + ctx.slash.select({ name: "docmd", description: "Run doc command", hints: [] }, textarea, setText) + + expect(textarea.value).toBe("/docmd existing text") + expect(currentText).toBe("/docmd existing text") + expect(selectionStart).toBe("/docmd ".length) + ctx.dispose() + }) + + it("uses slashEnd for server commands when onInput fired before select", () => { + const ctx = setup(() => {}) + let currentText = "" + let selectionStart = 0 + + ctx.slash.onInput("/docmdexisting text", 6) + + const textarea = { + value: "/docmdexisting text", + selectionStart: 2, + setSelectionRange: (start: number, end: number) => { + selectionStart = start + }, + focus: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + ctx.slash.select({ name: "docmd", description: "Run doc command", hints: [] }, textarea, setText) + + expect(textarea.value).toBe("/docmd existing text") + expect(currentText).toBe("/docmd existing text") + expect(selectionStart).toBe("/docmd ".length) + ctx.dispose() + }) + + it("preserves trailing text even when cursor moves after typing slash command", () => { + let actionCalls = 0 + let currentText = "existing text" + const ctx = setup(() => {}) + + // Type slash command: cursor at 4, slashEnd stored as 4 + ctx.slash.onInput("/newexisting text", 4) + + // Simulate user moving cursor (e.g. ArrowLeft twice) + const textarea = { + value: "/newexisting text", + selectionStart: 2, + setSelectionRange: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + ctx.slash.select( + { + name: "new", + description: "Start a new session", + hints: [], + action: () => { + actionCalls++ + }, + }, + textarea, + setText, + ) + + // Should preserve trailing text from original slashEnd (4), not stale selectionStart (2) + expect(textarea.value).toBe("existing text") + expect(currentText).toBe("existing text") + expect(actionCalls).toBe(1) + ctx.dispose() + }) + + it("preserves trailing text for memory commands when cursor moves", () => { + let currentText = "" + const ctx = setup(() => {}) + + // Type /memory rem: matches memory pattern, slashEnd set to end of text + const typed = "/memory rem" + ctx.slash.onInput(typed, typed.length) + + // Simulate user moving cursor (no onInput fires for arrow keys) + const textarea = { + value: typed, + selectionStart: 3, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + // Find the memory remember command (nested, no action) + const remembered = ctx.slash.results().find((c) => c.name === "memory remember") + expect(remembered).toBeDefined() + + ctx.slash.select(remembered!, textarea, setText) + + // Trailing text from slashEnd (end of typed text) — empty — should be preserved correctly + expect(currentText).toBe("/memory remember ") + expect(textarea.value).toBe("/memory remember ") + ctx.dispose() + }) + + it("preserves trailing text through the two-step nested command path", () => { + let currentText = "" + const ctx = setup(() => {}) + + ctx.slash.onInput("/mem", 4) + + const textarea = { + value: "/mem", + selectionStart: 4, + setSelectionRange: () => {}, + focus: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + const memory = ctx.slash.results().find((c) => c.name === "memory") + expect(memory).toBeDefined() + ctx.slash.select(memory!, textarea, setText) + expect(currentText).toBe("/memory ") + expect(textarea.value).toBe("/memory ") + + const remember = ctx.slash.results().find((c) => c.name === "memory remember") + expect(remember).toBeDefined() + ctx.slash.select(remember!, textarea, setText) + expect(currentText).toBe("/memory remember ") + expect(textarea.value).toBe("/memory remember ") + ctx.dispose() + }) + + it("keeps trailing text and cursor before it through nested selection", () => { + const ctx = setup(() => {}) + let currentText = "hello" + const textarea = { + value: "hello", + selectionStart: 0, + setSelectionRange: (start: number, _end: number) => { + textarea.selectionStart = start + }, + focus: () => {}, + } as unknown as HTMLTextAreaElement + const setText = (text: string) => { + currentText = text + } + + textarea.value = "/memhello" + textarea.selectionStart = 4 + ctx.slash.onInput("/memhello", 4) + + const memory = ctx.slash.results().find((c) => c.name === "memory") + expect(memory).toBeDefined() + ctx.slash.select(memory!, textarea, setText) + expect(textarea.value).toBe("/memory hello") + expect(textarea.selectionStart).toBe("/memory ".length) + + const rebuild = ctx.slash.results().find((c) => c.name === "memory rebuild") + expect(rebuild).toBeDefined() + ctx.slash.select(rebuild!, textarea, setText) + + expect(textarea.value).toBe("/memory rebuild hello") + expect(textarea.selectionStart).toBe("/memory rebuild ".length) + ctx.dispose() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 80c6314e6c8..8b2742313e7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -539,7 +539,14 @@ const AgentManagerContent: Component = () => { const togglePRPanel = () => { setHistory(false) if (reviewActive()) closeReviewTab() + const opening = sidePanel() !== SidePanel.PR setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR)) + // Trigger an immediate refresh when opening so the panel shows fresh data + // rather than waiting for the next poll cycle + if (opening) { + const sel = selection() + if (sel && sel !== LOCAL) vscode.postMessage({ type: "agentManager.refreshPR", worktreeId: sel }) + } } const openSelectedPR = () => { @@ -2632,23 +2639,19 @@ const AgentManagerContent: Component = () => { /> - {(() => { - const data = activePR()! - return ( - setSidePanel(null)} - onOpenExternal={() => - vscode.postMessage({ - type: "agentManager.openPR", - worktreeId: data.selected, - url: data.pr.url, - }) - } - /> - ) - })()} + setSidePanel(null)} + onOpenExternal={() => + vscode.postMessage({ + type: "agentManager.openPR", + worktreeId: activePR()!.selected, + url: activePR()!.pr.url, + }) + } + /> { const list = variants() if (list.length === 0) return undefined const stored = variant() - return stored && list.includes(stored) ? stored : list[0] + return stored && list.includes(stored) ? stored : undefined }) // True when the user has changed the model from the session/config default @@ -268,7 +268,7 @@ export const NewWorktreeDialog: Component<{ return } const stored = variant() - if (!stored || !list.includes(stored)) setVariant(preserveVariant(stored, list) ?? list[0]) + if (stored && !list.includes(stored)) setVariant(preserveVariant(stored, list)) }) createEffect(() => { @@ -526,7 +526,6 @@ export const NewWorktreeDialog: Component<{ const list = variants() if (list.length === 0) return const next = cycleVariant(effectiveVariant(), list) - if (!next) return e.preventDefault() setVariant(next) return @@ -889,6 +888,9 @@ export const NewWorktreeDialog: Component<{ variants={variants()} value={effectiveVariant()} onSelect={setVariant} + onClear={() => setVariant(undefined)} + allowClear + clearLabel={t("common.default")} trigger={WORKTREE_PROMPT_SCOPE} portal={false} deferDismiss diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx index b73c915f02d..91150230566 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx @@ -1,12 +1,126 @@ /** @jsxImportSource solid-js */ -import { For, Show, createSignal } from "solid-js" +import { Index, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { Markdown } from "@kilocode/kilo-ui/markdown" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { useVSCode } from "../../src/context/vscode" import type { PRStatus } from "../../src/types/messages" import type { PRComment } from "./pr-types" import { SectionHeading } from "./SectionHeading" import { CopyButton } from "./CopyButton" -export function PRComments(props: { comments: NonNullable }) { +function DiffHunk(props: { hunk: string }) { + const lines = () => props.hunk.split("\n") + return ( +
+ + {(line) => { + const text = line() + const cls = text.startsWith("+") + ? "am-pr-diff-line-add" + : text.startsWith("-") + ? "am-pr-diff-line-del" + : text.startsWith("@@") + ? "am-pr-diff-line-meta" + : "am-pr-diff-line-ctx" + return
{text || " "}
+ }} +
+
+ ) +} + +function CommentCard(props: { comment: PRComment; worktreeId: string }) { + const vscode = useVSCode() + + // Track pending action and any error from the result + const [pendingResolved, setPendingResolved] = createSignal(undefined) + const [actionError, setActionError] = createSignal(undefined) + + // Resolved shows pending state if exists, otherwise server state + const resolved = createMemo(() => pendingResolved() ?? props.comment.resolved) + + // Clear pending when server state matches (action confirmed by poll) + createMemo(() => { + const pending = pendingResolved() + if (pending !== undefined && pending === props.comment.resolved) { + setPendingResolved(undefined) + setActionError(undefined) + } + }) + + onMount(() => { + function handler(ev: MessageEvent) { + const msg = ev.data + const isResult = + (msg?.type === "agentManager.resolveCommentResult" || msg?.type === "agentManager.unresolveCommentResult") && + msg.worktreeId === props.worktreeId && + msg.threadId === props.comment.threadId + if (!isResult) return + if (!msg.success) { + // Only clear on error - success waits for poll to update props.comment.resolved + setPendingResolved(undefined) + setActionError( + msg.type === "agentManager.resolveCommentResult" + ? "Failed to resolve thread." + : "Failed to unresolve thread.", + ) + } + } + window.addEventListener("message", handler) + onCleanup(() => window.removeEventListener("message", handler)) + }) + + function toggle() { + setActionError(undefined) + const next = !resolved() + setPendingResolved(next) + vscode.postMessage({ + type: next ? "agentManager.resolveComment" : "agentManager.unresolveComment", + worktreeId: props.worktreeId, + threadId: props.comment.threadId, + } as never) + } + + return ( +
+ {(hunk) => } +
+ {props.comment.author} + + + {props.comment.file} + {`:${props.comment.line}`} + + + + Resolved + + +
+ {(err) =>
{err()}
}
+
+ +
+
+ + + Loading +
+ } + > + + +
+
+ ) +} + +export function PRComments(props: { comments: NonNullable; worktreeId: string }) { const [open, setOpen] = createSignal(true) return ( <> @@ -21,28 +135,9 @@ export function PRComments(props: { comments: NonNullable />
- - {(comment: PRComment) => ( -
-
- {comment.author} - - - {comment.file} - {`:${comment.line}`} - - - - Resolved - - -
-
- -
-
- )} -
+ + {(comment) => } +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index ec37c45940a..b37d72948d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource solid-js */ -import { Component, Show } from "solid-js" +import { Component, Show, createSignal } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { WorktreeState } from "../../src/types/messages" @@ -16,11 +16,29 @@ import "./pr-panel.css" interface PRPanelProps { pr: PRStatus worktree?: WorktreeState + worktreeId: string onClose: () => void onOpenExternal: () => void } export const PRPanel: Component = (props) => { + let bodyRef: HTMLDivElement | undefined + let commentsRef: HTMLDivElement | undefined + const [showScrollTop, setShowScrollTop] = createSignal(false) + + function onScroll(e: Event) { + const el = e.target as HTMLDivElement + setShowScrollTop(el.scrollTop > 100) + } + + function scrollToTop() { + bodyRef?.scrollTo({ top: 0, behavior: "smooth" }) + } + + function jumpToComments() { + commentsRef?.scrollIntoView({ behavior: "smooth", block: "start" }) + } + return (
@@ -44,18 +62,31 @@ export const PRPanel: Component = (props) => {
-
- - - 0}> - - - {(body) => } - 0}> - - - - {(comments) => } +
+
+ + + 0}> + + + {(body) => } + 0}> + + + + {(comments) => ( +
+ +
+ )} +
+
+ + + +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx index 48d9d2ee2e8..0b8ee91307a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -5,9 +5,10 @@ import type { PRStatus } from "../../src/types/messages" interface PRSummaryProps { pr: PRStatus + onJumpToComments?: () => void } -function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> { +function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string; isComments?: boolean }> { const rows = [] if (pr.checks.total > 0) { @@ -28,11 +29,18 @@ function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: }) } - if (pr.comments && pr.comments.unresolved > 0) { + if (pr.comments && pr.comments.total > 0) { + const unresolved = pr.comments.unresolved + const total = pr.comments.total + const label = + unresolved > 0 + ? `${unresolved} unresolved comment${unresolved > 1 ? "s" : ""}` + : `${total} comment${total > 1 ? "s" : ""}` rows.push({ icon: "comment", - label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`, - status: "warning", + label, + status: unresolved > 0 ? "warning" : "success", + isComments: true, }) } @@ -59,12 +67,28 @@ export function PRSummary(props: PRSummaryProps) {
- {rows().map((row) => ( -
- - {row.label} -
- ))} + {rows().map((row) => { + const isClickable = !!(row.isComments && props.onJumpToComments) + const rowProps = { + class: "am-pr-summary-row am-pr-row", + classList: { "am-pr-summary-row-link": isClickable }, + "data-status": row.status, + } + const content = ( + <> + + {row.label} + {row.isComments && props.onJumpToComments && Jump to comments ↓} + + ) + return isClickable ? ( + + ) : ( +
{content}
+ ) + })}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index e505989acee..55c14665088 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -67,10 +67,39 @@ flex-shrink: 0; } -.am-pr-panel-body { +.am-pr-panel-body-wrap { flex: 1; + position: relative; + overflow: hidden; +} + +.am-pr-panel-body { + height: 100%; overflow-y: auto; padding: 8px 0; + overflow-anchor: none; +} + +.am-pr-scroll-top { + position: absolute; + bottom: 16px; + right: 16px; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--vscode-foreground); + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + cursor: pointer; + font-size: var(--kilo-font-size-14); + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; +} + +.am-pr-scroll-top:hover { + opacity: 1; } .am-pr-panel-section { @@ -255,6 +284,87 @@ margin-left: auto; } +/* Diff hunk preview inside comment cards */ +.am-pr-diff-hunk { + font-family: var(--font-mono, monospace); + font-size: var(--kilo-font-size-11); + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; + border: 1px solid var(--vscode-panel-border); +} + +.am-pr-diff-line { + padding: 1px 6px; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.am-pr-diff-line-add { + background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent); + color: var(--syntax-diff-add, #318430); +} + +.am-pr-diff-line-del { + background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent); + color: var(--syntax-diff-delete, #da3319); +} + +.am-pr-diff-line-meta { + color: var(--text-weaker); +} + +.am-pr-diff-line-ctx { + color: var(--vscode-foreground); +} + +/* Resolve button */ +.am-pr-resolve-row { + display: flex; + justify-content: flex-start; + margin-top: 15px; +} + +.am-pr-resolve-btn { + background: none; + border: 1px solid var(--vscode-panel-border); + border-radius: 3px; + color: var(--text-weak); + cursor: pointer; + font-size: var(--kilo-font-size-14); + padding: 4px 16px; +} + +.am-pr-resolve-btn:hover { + color: var(--vscode-foreground); + border-color: var(--vscode-foreground); +} +.am-pr-resolve-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.am-pr-resolve-error { + font-size: var(--kilo-font-size-11); + color: var(--vscode-testing-iconFailed, #f87171); + padding: 2px 0 4px; +} + +.am-pr-resolve-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 16px; + font-size: var(--kilo-font-size-14); + color: var(--text-weak); +} + +.am-pr-resolve-spinner { + width: 14px; + height: 14px; +} + .am-pr-panel-comment-body [data-component="markdown"] { font-size: var(--kilo-font-size-12); color: var(--vscode-foreground); @@ -455,3 +565,39 @@ .am-pr-summary-label { color: var(--vscode-foreground); } + +.am-pr-summary-jump { + margin-left: auto; + font-size: var(--kilo-font-size-12); + color: var(--text-weaker); +} + +.am-pr-summary-row-link { + cursor: pointer; + border-radius: 3px; + padding-left: 2px; + padding-right: 2px; +} + +button.am-pr-summary-row-link { + all: unset; + display: flex; + width: 100%; + gap: 7px; + padding: 2px; + font-size: var(--kilo-font-size-12); + box-sizing: border-box; + cursor: pointer; +} + +button.am-pr-summary-row-link:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} + +.am-pr-summary-row-link:hover { + background: var(--vscode-list-hoverBackground); +} + +.am-pr-summary-row-link:hover .am-pr-summary-jump { + color: var(--vscode-foreground); +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts index d5d0a89d6c6..99bf2d6146e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts @@ -15,6 +15,7 @@ export interface PRCheck { export interface PRComment { id: string + threadId: string author: string avatar?: string body: string @@ -23,6 +24,7 @@ export interface PRComment { url?: string resolved: boolean createdAt?: number + diffHunk?: string } export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index e8dafa01c5c..a0162ce04e3 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -120,8 +120,8 @@ export const DataBridge: Component<{ children: any }> = (props) => { session.rejectQuestion(input.requestID) } - const open = (filePath: string, line?: number, column?: number) => { - vscode.postMessage({ type: "openFile", filePath, line, column }) + const open = (filePath: string, line?: number, column?: number, sessionID?: string) => { + vscode.postMessage({ type: "openFile", filePath, line, column, sessionID }) } const openDiff = (diff: { file: string; patch?: string; additions: number; deletions: number }) => { @@ -139,17 +139,23 @@ export const DataBridge: Component<{ children: any }> = (props) => { // File existence validation for code span candidates const pending = new Map void>() const counter = { n: 0 } - const validateFiles = (paths: string[]): Promise => { + const validateFiles = (sessionID: string, paths: string[]): Promise => { const id = `vf-${++counter.n}` - return new Promise((resolve) => { - pending.set(id, resolve) - vscode.postMessage({ type: "validateFiles", id, paths }) - setTimeout(() => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { if (pending.has(id)) { pending.delete(id) - resolve([]) + // A timeout is not the same as "checked and none exist" — reject so + // callers don't cache a false negative for real files on a slow + // filesystem (see file-link-validator.ts). + reject(new Error("validateFiles timed out")) } }, 3000) + pending.set(id, (existing) => { + clearTimeout(timer) + resolve(existing) + }) + vscode.postMessage({ type: "validateFiles", id, sessionID, paths }) }) } const handler = (event: MessageEvent) => { 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 da9e7789a8f..5a8255befea 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -52,6 +52,7 @@ import { isPromptBusy, isPathMention, applySandboxStates, + memoryRest, type SandboxDefaultState, type SandboxState, } from "./prompt-input-utils" @@ -80,7 +81,7 @@ import { import { ReviewComments } from "./ReviewComments" import { partReview, reviewBody } from "../../../../src/shared/review-comments" import { isEnterKeyCommitNotIme } from "../../utils/ime-enter" -import { parseMemoryCommand } from "../../utils/memory-command" +import { parseMemoryCommand, type ParsedMemoryCommand } from "../../utils/memory-command" import { useMemory } from "../../context/memory" function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] { @@ -943,7 +944,6 @@ export const PromptInput: Component = (props) => { const list = session.variantList(sid()) if (list.length === 0) return const next = cycleVariant(session.currentVariant(sid()), list) - if (!next) return e.preventDefault() session.selectVariant(next, sid()) return @@ -1125,6 +1125,16 @@ export const PromptInput: Component = (props) => { return true } + const setMemoryText = (memory: ParsedMemoryCommand) => { + const rest = memoryRest(memory) + setText(rest) + if (textareaRef) { + textareaRef.value = rest + textareaRef.setSelectionRange(0, 0) + textareaRef.focus() + } + } + const handleSend = async () => { const draft = text().trim() @@ -1132,7 +1142,7 @@ export const PromptInput: Component = (props) => { if (memory) { if (!runMemory(memory)) return history.append(draft) - setText("") + setMemoryText(memory) clearReviewComments() imageAttach.clear() mention.closeMention() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts index e97f58ac39d..f3fb4874b57 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts @@ -1,3 +1,5 @@ +import { type ParsedMemoryCommand } from "../../utils/memory-command" + export type SandboxDefaultState = { desired: boolean enabled: boolean @@ -150,3 +152,15 @@ export function isPathMention(text: string): boolean { const path = text.replace(/^@/, "") return path !== "terminal" && path !== "git-changes" } + +/** + * The text that should remain in the prompt input after a memory command is + * submitted. No-argument memory operations (e.g. rebuild, on, status, inspect) + * typed with trailing free text (e.g. "/memory rebuild hello") keep that text in + * the input instead of discarding it; the parser reports the unconsumed + * remainder as `rest`. Argument-taking operations (remember, correct, forget, + * auto, purge) consume their text, so nothing remains. + */ +export function memoryRest(cmd: ParsedMemoryCommand): string { + return "rest" in cmd ? (cmd.rest ?? "") : "" +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index e97b1cff6df..37d04b6d63a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -18,7 +18,7 @@ import ModeEditView from "./ModeEditView" import ModeCreateView from "./ModeCreateView" import McpEditView from "./McpEditView" import WorkflowsTab from "./agent-behaviour/WorkflowsTab" -import { mcpConfigScope, mcpEnabledPatch, selectedDefaultAgentValue } from "./agent-behaviour-patches" +import { mcpConfigScope, mcpEnabledPatch, removable, selectedDefaultAgentValue } from "./agent-behaviour-patches" import { parseImport, MAX_IMPORT_SIZE } from "./mode-io" import type { ImportError } from "./mode-io" @@ -368,6 +368,7 @@ const AgentBehaviourTab: Component = () => { {(name, index) => { const agent = () => session.allAgents().find((a) => a.name === name) const isCustom = () => !agent()?.native + const allowed = () => removable(agent()) const agentCfg = () => config().agent?.[name] ?? {} const disabled = () => agentCfg().disable ?? false const hidden = () => agentCfg().hidden ?? false @@ -477,7 +478,7 @@ const AgentBehaviourTab: Component = () => {
- + = (props) => { title={language.t("settings.agentBehaviour.exportMode")} onClick={exportMode} /> - { - const a = agent() - if (a) props.onRemove(a) - }} - /> + + { + const a = agent() + if (a) props.onRemove(a) + }} + /> +
diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx index 5f64842ce3d..7c95dfc9453 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderConnectDialog.tsx @@ -35,10 +35,6 @@ interface ViewState { type Prompt = NonNullable[number] -function fallbackMethods(label: string): ProviderAuthMethod[] { - return [{ type: "api", label }] -} - function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "message" in value) { const message = (value as { message?: unknown }).message @@ -70,8 +66,61 @@ const ProviderConnectDialog: Component = (props) => const item = createMemo(() => provider.providers()[props.providerID]) const name = () => item()?.name ?? props.providerID const methods = createMemo(() => { - const list = - provider.authMethods()[props.providerID] ?? fallbackMethods(language.t("provider.connect.method.apiKey")) + const fallback = (): ProviderAuthMethod[] => { + if (props.providerID === "amazon-bedrock") { + return [ + { + type: "api", + label: language.t("provider.connect.bedrock.method.accessKeys"), + prompts: [ + { + type: "text", + key: "secretAccessKey", + message: language.t("provider.connect.bedrock.secretAccessKey.label"), + placeholder: language.t("provider.connect.bedrock.secretAccessKey.placeholder"), + }, + { + type: "text", + key: "sessionToken", + message: language.t("provider.connect.bedrock.sessionToken.label"), + placeholder: language.t("provider.connect.bedrock.sessionToken.placeholder"), + }, + { + type: "text", + key: "region", + message: language.t("provider.connect.bedrock.region.label"), + placeholder: language.t("provider.connect.bedrock.region.placeholder"), + }, + ], + }, + { type: "api", label: language.t("provider.connect.bedrock.method.apiKey") }, + ] + } + if (props.providerID === "google-vertex") { + return [ + { + type: "api", + label: language.t("provider.connect.vertex.method.serviceAccount"), + prompts: [ + { + type: "text", + key: "project", + message: language.t("provider.connect.vertex.project.label"), + placeholder: language.t("provider.connect.vertex.project.placeholder"), + }, + { + type: "text", + key: "location", + message: language.t("provider.connect.vertex.location.label"), + placeholder: language.t("provider.connect.vertex.location.placeholder"), + }, + ], + }, + ] + } + return [{ type: "api", label: language.t("provider.connect.method.apiKey") }] + } + const list = provider.authMethods()[props.providerID] ?? fallback() if (props.oauthOnly) return list.filter((item) => item.type === "oauth") return list }) @@ -79,6 +128,15 @@ const ProviderConnectDialog: Component = (props) => const index = state.methodIndex return index === undefined ? undefined : methods()[index] }) + const bedrockKeys = () => + props.providerID === "amazon-bedrock" && method()?.prompts?.some((prompt) => prompt.key === "secretAccessKey") + const vertexCredentials = () => props.providerID === "google-vertex" && method()?.type === "api" + + function optional(prompt: Prompt) { + if (bedrockKeys() && prompt.key === "sessionToken") return true + if (vertexCredentials() && prompt.key === "project") return true + return false + } function promptLabel(prompt: Prompt) { if (props.providerID === "azure" && prompt.key === "endpointType") { @@ -294,6 +352,12 @@ const ProviderConnectDialog: Component = (props) => const apiKeyOptional = () => isLocalProviderOptionalApiKey(props.providerID) function apiKeyDescription() { + if (bedrockKeys()) { + return language.t("provider.connect.bedrock.description") + } + if (vertexCredentials()) { + return language.t("provider.connect.vertex.description") + } if (props.providerID === ATOMIC_CHAT_PROVIDER_KEY) { return language.t("provider.connect.atomicChat.description") } @@ -304,24 +368,52 @@ const ProviderConnectDialog: Component = (props) => } function apiKeyLabel() { + if (bedrockKeys()) { + return language.t("provider.connect.bedrock.accessKeyId.label") + } + if (vertexCredentials()) { + return language.t("provider.connect.vertex.credentials.label") + } if (apiKeyOptional()) { return language.t("provider.connect.apiKey.label.optional", { provider: name() }) } return language.t("provider.connect.apiKey.label", { provider: name() }) } + function apiKeyRequired() { + if (bedrockKeys()) return language.t("provider.connect.bedrock.accessKeyId.required") + if (vertexCredentials()) return language.t("provider.connect.vertex.credentials.required") + return language.t("provider.connect.apiKey.required") + } + function submit(e: SubmitEvent) { e.preventDefault() const trimmed = value().trim() const apiKey = trimmed || (apiKeyOptional() ? LOCAL_PROVIDER_API_KEY_PLACEHOLDER : "") if (!apiKey) { - setState({ ...state, error: language.t("provider.connect.apiKey.required"), field: "apiKey" }) + setState({ ...state, error: apiKeyRequired(), field: "apiKey" }) + return + } + const serviceAccount = (() => { + if (!vertexCredentials()) return undefined + try { + const parsed = JSON.parse(apiKey) as Record + if (parsed.type !== "service_account") return undefined + if (typeof parsed.client_email !== "string" || !parsed.client_email.trim()) return undefined + if (typeof parsed.private_key !== "string" || !parsed.private_key.trim()) return undefined + return parsed + } catch { + return undefined + } + })() + if (vertexCredentials() && !serviceAccount) { + setState({ ...state, error: language.t("provider.connect.vertex.credentials.invalid"), field: "apiKey" }) return } const metadata: Record = {} for (const prompt of prompts()) { const field = (fields[prompt.key] ?? "").trim() - if (!field) { + if (!field && !optional(prompt)) { setState({ ...state, error: language.t("provider.connect.prompt.required", { field: promptLabel(prompt) }), @@ -329,8 +421,23 @@ const ProviderConnectDialog: Component = (props) => }) return } + if (!field) continue metadata[prompt.key] = field } + if ( + vertexCredentials() && + !metadata.project && + !(typeof serviceAccount?.project_id === "string" && serviceAccount.project_id.trim()) + ) { + setState({ + ...state, + error: language.t("provider.connect.vertex.project.required"), + field: "project", + }) + return + } + if (bedrockKeys()) metadata.authType = "accessKey" + if (vertexCredentials()) metadata.authType = "serviceAccount" connect(apiKey, Object.keys(metadata).length > 0 ? metadata : undefined) } @@ -343,12 +450,23 @@ const ProviderConnectDialog: Component = (props) =>
{apiKeyDescription()}
= (props) => { return { diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index 7f58fc6402c..2fc4229ac33 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -55,7 +55,10 @@ export const ThinkingSelectorBase: Component = (props const language = useLanguage() let listRef: HTMLDivElement | undefined - const rows = () => (props.allowClear ? [undefined, ...props.variants] : props.variants) + const rows = () => { + if (props.variants.length === 0 && !props.value) return [] + return props.allowClear ? [undefined, ...props.variants] : props.variants + } const clearLabel = () => props.clearLabel ?? "Not set" function display(value: string | undefined) { @@ -221,6 +224,7 @@ interface ThinkingSelectorProps { export const ThinkingSelector: Component = (props) => { const session = useSession() const { settings } = useConfig() + const language = useLanguage() const id = () => props.sessionID?.() return ( @@ -228,6 +232,9 @@ export const ThinkingSelector: Component = (props) => { variants={session.variantList(id())} value={session.currentVariant(id())} onSelect={(value) => session.selectVariant(value, id())} + onClear={() => session.selectVariant(undefined, id())} + allowClear + clearLabel={language.t("common.default")} cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false} /> ) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-agent.ts b/packages/kilo-vscode/webview-ui/src/context/session-agent.ts index 18676c80322..87d243295a5 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-agent.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-agent.ts @@ -26,6 +26,15 @@ export function resolveSessionAgent(messages: Message[], names: Set): st } } +export function resolvePromptAgent(input: { + sessionID?: string + selections: Record + pending: string | null +}) { + if (input.sessionID) return input.selections[input.sessionID] + return input.pending ?? undefined +} + export function draftAgentSelection(selections: Record, draft: string, pending: string | null) { if (selections[draft]) return undefined return pending ?? undefined diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts index 0eb363b0d29..a1a9a9ceee3 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts @@ -1,6 +1,8 @@ import type { ModelSelection } from "../types/messages" const effort = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] +// Variant names are non-empty, so this cannot collide with a provider variant. +export const DEFAULT_VARIANT = "" /** Keep the selected effort when possible, falling back to the nearest known effort. */ export function preserveVariant(current: string | undefined, variants: string[]) { @@ -42,7 +44,8 @@ export function getVariant( const key = variantKey(sel, agent, session) const fallback = session ? store[variantKey(sel, agent)] : undefined const stored = store[key] ?? fallback ?? store[legacyVariantKey(sel)] - return preserveVariant(stored, variants) ?? variants[0] + if (stored === undefined || stored === DEFAULT_VARIANT) return undefined + return preserveVariant(stored, variants) } export function getAgentVariant( @@ -56,12 +59,13 @@ export function getAgentVariant( } /** - * Next variant in the list, wrapping back to the first after the last. + * Next variant in the list, returning to the default after the last. * An unknown or missing current value starts at the first variant. */ export function cycleVariant(current: string | undefined, variants: string[]) { if (variants.length === 0) return undefined const idx = current ? variants.indexOf(current) : -1 + if (idx === variants.length - 1) return undefined return variants[(idx + 1) % variants.length] } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts index 5902605944b..f3d2745f353 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-variants.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-variants.ts @@ -1,6 +1,6 @@ import type { Accessor } from "solid-js" import type { ExtensionMessage, ModelSelection } from "../types/messages" -import { getAgentVariant, getVariant, preserveVariant, variantKey } from "./session-variant-store" +import { DEFAULT_VARIANT, getAgentVariant, getVariant, preserveVariant, variantKey } from "./session-variant-store" interface Model { variants?: Record @@ -40,18 +40,21 @@ export function createSessionVariants(options: Options) { return getVariant(options.selections(), selection, variants, options.agent(sid), sid) } - const select = (value: string, sessionID?: string) => { + const select = (value: string | undefined, sessionID?: string) => { const sid = sessionID ?? options.session() const selection = options.selected(sid) if (!selection) return const key = variantKey(selection, options.agent(sid), sid) - options.set(key, value) - if (!sid) options.post({ type: "persistVariant", key, value }) + const next = value ?? DEFAULT_VARIANT + options.set(key, next) + if (!sid) options.post({ type: "persistVariant", key, value: next }) } const carry = (selection: ModelSelection, value: string | undefined, name: string, sessionID?: string) => { - const next = preserveVariant(value, Object.keys(options.find(selection)?.variants ?? {})) - if (!next) return + const list = Object.keys(options.find(selection)?.variants ?? {}) + if (list.length === 0) return + const next = value === undefined ? DEFAULT_VARIANT : preserveVariant(value, list) + if (next === undefined) return const key = variantKey(selection, name, sessionID) options.set(key, next) if (!sessionID) options.post({ type: "persistVariant", key, value: next }) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index cb31ff49af9..d8f0b44d24b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -85,7 +85,7 @@ import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/dra import { createAbortState } from "./abort-state" import { clearIfOn, createCloudPrune } from "./session-cloud-prune" import { isSameSessionTree } from "./model-usage" -import { createDraftAgentSeed } from "./session-agent" +import { createDraftAgentSeed, resolvePromptAgent } from "./session-agent" import { createModelSelector } from "./session-model-selector" const RECENT_LIMIT = 5 @@ -240,7 +240,7 @@ interface SessionContextValue { variantList: (sessionID?: string) => string[] currentVariant: (sessionID?: string) => string | undefined variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined - selectVariant: (value: string, sessionID?: string) => void + selectVariant: (value: string | undefined, sessionID?: string) => void // Model favorites recentModels: Accessor @@ -717,8 +717,11 @@ export const SessionProvider: ParentComponent = (props) => { }) function promptAgent(sessionID?: string) { - const name = agentForScope(sessionID) - return name !== defaultAgent() ? name : undefined + return resolvePromptAgent({ + sessionID, + selections: store.agentSelections, + pending: pendingAgentSelection(), + }) } function hideErrors(sid: string) { @@ -1386,7 +1389,7 @@ export const SessionProvider: ParentComponent = (props) => { if (prefs.model && prefs.variant) { const agent = prefs.agent ?? store.agentSelections[sessionID] ?? defaultAgent() const key = variantKey(prefs.model, agent, sessionID) - if (!store.variantSelections[key]) setStore("variantSelections", key, prefs.variant) + if (store.variantSelections[key] === undefined) setStore("variantSelections", key, prefs.variant) } } @@ -2549,9 +2552,9 @@ export const SessionProvider: ParentComponent = (props) => { return } - // Reset agent selection to default for the new session (model overrides persist) + // Clear the pending agent so the picker shows the default and send omits it agentDrafts.prune(draftSessionID()) - setPendingAgentSelection(defaultAgent()) + setPendingAgentSelection(null) vscode.postMessage({ type: "createSession" }) } @@ -2562,7 +2565,7 @@ export const SessionProvider: ParentComponent = (props) => { setDraftSessionID(undefined) setCloudPreviewId(null) setLoading(false) - setPendingAgentSelection(defaultAgent()) + setPendingAgentSelection(null) vscode.postMessage({ type: "clearSession" }) } diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index d5192ad2a33..dc377e8c22d 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -64,6 +64,7 @@ export function useSlashCommand( const [query, setQuery] = createSignal(null) const [index, setIndex] = createSignal(0) const [requested, setRequested] = createSignal(false) + const [slashEnd, setSlashEnd] = createSignal(null) const open = (name: string) => { window.dispatchEvent(new CustomEvent(name, { detail: { source: scope } })) } @@ -287,6 +288,7 @@ export function useSlashCommand( const close = () => { setQuery(null) + setSlashEnd(null) } const onInput = (val: string, cursor: number) => { @@ -296,6 +298,7 @@ export function useSlashCommand( request() setQuery(match[1]) setIndex(0) + setSlashEnd(cursor) return } const memory = before.match(/^\/(?:memory|mem)\s+([^\n]*)$/i) @@ -305,6 +308,7 @@ export function useSlashCommand( request() setQuery(value) setIndex(0) + setSlashEnd(cursor) return } const review = before.match(/^\/review\s+([^\n]*)$/i) @@ -314,6 +318,7 @@ export function useSlashCommand( request() setQuery(value) setIndex(0) + setSlashEnd(cursor) return } return close() @@ -325,24 +330,31 @@ export function useSlashCommand( setText: (text: string) => void, onSelect?: () => void, ) => { + const cursor = slashEnd() ?? textarea.selectionStart ?? 0 + // trailingText holds text after the slash command. + // slashEnd is the cursor position from onInput when the slash pattern was matched. + const trailingText = textarea.value.substring(cursor) + if (cmd.action) { if (cmd.enabled && !cmd.enabled()) return - textarea.value = "" - setText("") + textarea.value = trailingText + setText(trailingText) + textarea.setSelectionRange(0, 0) close() onSelect?.() cmd.action() return } - const text = `/${cmd.name} ` - textarea.value = text - setText(text) - const pos = text.length - textarea.setSelectionRange(pos, pos) + const commandText = `/${cmd.name} ` + const updatedText = commandText + trailingText + textarea.value = updatedText + setText(updatedText) + textarea.setSelectionRange(commandText.length, commandText.length) textarea.focus() if (cmd.nested) { setQuery(`${cmd.name} `) setIndex(0) + setSlashEnd(commandText.length) } if (!cmd.nested) close() onSelect?.() diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 202493275cd..9d8c6030c9c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "توصيل Anaconda Desktop", "provider.anaconda.title.manage": "إدارة Anaconda Desktop", @@ -52,6 +54,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "اتصال بموفر", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 9d5e763be59..c4a34815652 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Conectar Anaconda Desktop", "provider.anaconda.title.manage": "Gerenciar Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Conectar provedor", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index ac4b3746191..dcade977e1f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Poveži Anaconda Desktop", "provider.anaconda.title.manage": "Upravljaj Anaconda Desktopom", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Poveži provajdera", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/cloud-provider.ts b/packages/kilo-vscode/webview-ui/src/i18n/cloud-provider.ts new file mode 100644 index 00000000000..ddd45c960fd --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/i18n/cloud-provider.ts @@ -0,0 +1,27 @@ +export const cloudProviderDict = { + "provider.connect.bedrock.method.accessKeys": "AWS access keys", + "provider.connect.bedrock.method.apiKey": "Bedrock API key", + "provider.connect.bedrock.description": + "Enter the AWS credentials Kilo should use to access Amazon Bedrock. These credentials are stored securely in Kilo's credential store.", + "provider.connect.bedrock.accessKeyId.label": "AWS access key ID", + "provider.connect.bedrock.accessKeyId.placeholder": "AKIA...", + "provider.connect.bedrock.accessKeyId.required": "AWS access key ID is required", + "provider.connect.bedrock.secretAccessKey.label": "AWS secret access key", + "provider.connect.bedrock.secretAccessKey.placeholder": "Secret access key", + "provider.connect.bedrock.sessionToken.label": "AWS session token (optional)", + "provider.connect.bedrock.sessionToken.placeholder": "Session token for temporary credentials", + "provider.connect.bedrock.region.label": "AWS region", + "provider.connect.bedrock.region.placeholder": "e.g. us-east-1", + "provider.connect.vertex.method.serviceAccount": "Google Cloud service account", + "provider.connect.vertex.description": + "Paste the complete Google Cloud service-account JSON. Kilo stores it securely in its credential store.", + "provider.connect.vertex.credentials.label": "Service-account JSON", + "provider.connect.vertex.credentials.placeholder": "Paste the complete JSON credentials", + "provider.connect.vertex.credentials.required": "Service-account JSON is required", + "provider.connect.vertex.credentials.invalid": "Enter valid service-account JSON credentials", + "provider.connect.vertex.project.label": "Google Cloud project ID (optional)", + "provider.connect.vertex.project.placeholder": "Uses project_id from the JSON by default", + "provider.connect.vertex.project.required": "The credentials must include project_id or a project ID override", + "provider.connect.vertex.location.label": "Vertex AI location", + "provider.connect.vertex.location.placeholder": "e.g. us-central1 or global", +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 2f487eaf391..eec6cc88e45 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Forbind Anaconda Desktop", "provider.anaconda.title.manage": "Administrer Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Tilslut udbyder", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index e4eac6e9025..4a616b5beb3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -2,6 +2,8 @@ import { dict as en } from "./en" type Keys = keyof typeof en +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Anaconda Desktop verbinden", "provider.anaconda.title.manage": "Anaconda Desktop verwalten", @@ -59,6 +61,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Anbieter verbinden", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 3546077ccf9..784b0ef7a36 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Connect Anaconda Desktop", "provider.anaconda.title.manage": "Manage Anaconda Desktop", @@ -52,6 +54,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Connect provider", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 63520fa5bd2..f7a90a75dfe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Conectar Anaconda Desktop", "provider.anaconda.title.manage": "Administrar Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Conectar proveedor", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index db97c908088..249012ca27a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "اتصال به Anaconda Desktop", "provider.anaconda.title.manage": "مدیریت Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "اتصال به ارائه‌دهنده", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 50988bd3396..1b11513f34f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Connecter Anaconda Desktop", "provider.anaconda.title.manage": "Gérer Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Connecter un fournisseur", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 61895adf707..7bc050784db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Connetti Anaconda Desktop", "provider.anaconda.title.manage": "Gestisci Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Connetti provider", "command.session.new": "Nuova sessione", "command.session.show.changes": "Mostra modifiche", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 354523ae841..61c0baa973b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Anaconda Desktop に接続", "provider.anaconda.title.manage": "Anaconda Desktop を管理", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "プロバイダーに接続", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index c8246557a5d..498461275bb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1,5 +1,7 @@ import { dict as en } from "./en" +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Anaconda Desktop 연결", "provider.anaconda.title.manage": "Anaconda Desktop 관리", @@ -57,6 +59,7 @@ type Keys = keyof typeof en export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "공급자 연결", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 825a6449fae..e48e983e9e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Anaconda Desktop verbinden", "provider.anaconda.title.manage": "Anaconda Desktop beheren", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Provider verbinden", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f86ca473ab3..43de1108d5f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1,6 +1,8 @@ import { dict as en } from "./en" type Keys = keyof typeof en +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Koble til Anaconda Desktop", "provider.anaconda.title.manage": "Administrer Anaconda Desktop", @@ -56,6 +58,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Koble til leverandør", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 0f1a32682f1..0dfe9b37ca7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Połącz Anaconda Desktop", "provider.anaconda.title.manage": "Zarządzaj Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Połącz dostawcę", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 8974b3d65e0..11d4609321a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Подключить Anaconda Desktop", "provider.anaconda.title.manage": "Управление Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Подключить провайдера", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 0ecb3bcc13e..0b8c21e23ab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "เชื่อมต่อ Anaconda Desktop", "provider.anaconda.title.manage": "จัดการ Anaconda Desktop", @@ -52,6 +54,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "เชื่อมต่อผู้ให้บริการ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index d5557822c1d..237e44943ec 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Anaconda Desktop'a bağlan", "provider.anaconda.title.manage": "Anaconda Desktop'ı yönet", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Sağlayıcı bağla", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 81956a2edc3..a678ceb45dc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1,3 +1,5 @@ +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "Підключити Anaconda Desktop", "provider.anaconda.title.manage": "Керувати Anaconda Desktop", @@ -53,6 +55,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "Підключити провайдера", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index db8476ba095..bb2b2ea0749 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -2,6 +2,8 @@ import { dict as en } from "./en" type Keys = keyof typeof en +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "连接 Anaconda Desktop", "provider.anaconda.title.manage": "管理 Anaconda Desktop", @@ -54,6 +56,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "连接提供商", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 5a68ee437b7..c534665dd7d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -2,6 +2,8 @@ import { dict as en } from "./en" type Keys = keyof typeof en +import { cloudProviderDict } from "./cloud-provider" + export const anacondaDesktopDict = { "provider.anaconda.title.connect": "連接 Anaconda Desktop", "provider.anaconda.title.manage": "管理 Anaconda Desktop", @@ -54,6 +56,7 @@ export const anacondaDesktopDict = { export const dict = { ...anacondaDesktopDict, + ...cloudProviderDict, "command.provider.connect": "連接供應商", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts index 90a69408180..928f3c6aaa4 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts @@ -22,6 +22,7 @@ export interface SlashCommandInfo { export interface AgentInfo { name: string displayName?: string + source?: string description?: string mode: "subagent" | "primary" | "all" native?: boolean diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index d444f7fb881..5192242d787 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -149,6 +149,10 @@ export interface OpenFileRequest { filePath: string line?: number column?: number + // Optional session id the file reference was rendered for. When present the + // extension resolves the workspace directory from this instead of its live + // `currentSession`, so a session switch can't open the wrong worktree's file. + sessionID?: string } export interface OpenContentRequest { @@ -160,6 +164,11 @@ export interface OpenContentRequest { export interface ValidateFilesRequest { type: "validateFiles" id: string + // Explicit session id the candidates were rendered for — the extension + // resolves the workspace directory from this instead of its own live + // `currentSession`, so a session switch mid-request can't validate paths + // against the wrong worktree. + sessionID: string paths: string[] } @@ -991,6 +1000,12 @@ export interface OpenPRMessage { url?: string } +export interface CommentActionMessage { + type: "agentManager.resolveComment" | "agentManager.unresolveComment" + worktreeId: string + threadId: string +} + export interface ApplyWorktreeDiffMessage { type: "agentManager.applyWorktreeDiff" worktreeId: string @@ -1532,6 +1547,7 @@ export type WebviewMessage = | SetDiffBaseBranchMessage | RefreshPRMessage | OpenPRMessage + | CommentActionMessage // legacy-migration start | RequestMigrationDataMessage | StartMigrationMessage diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 1ef02a9db26..57f38a12d65 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index dc372c63bb9..02b3d922f81 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.21", + "version": "7.4.22", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 963d9d57e1b..a6b0091fcf8 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,60 @@ # @kilocode/cli +## 7.4.22 + +### Minor Changes + +- [#13084](https://github.com/Kilo-Org/kilocode/pull/13084) [`5c97b48`](https://github.com/Kilo-Org/kilocode/commit/5c97b481d233d294859bcb737448661910fd4916) - Remove the built-in experimental Morph WarpGrep codebase search tool and ignore its retired configuration flag. + +- [#12809](https://github.com/Kilo-Org/kilocode/pull/12809) [`907f7df`](https://github.com/Kilo-Org/kilocode/commit/907f7dfcf398e6ce44d8ee59dc031b3a1da5464f) Thanks [@bagatao-anaconda](https://github.com/bagatao-anaconda)! - Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there, placed at `.kilo/worktrees/` alongside worktrees created by the VS Code extension's Agent Manager. Also adds `kilo worktree create/list/remove` for managing worktrees without launching the TUI, and a `/worktree` command in the TUI to list and remove them. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. + +### Patch Changes + +- [#13102](https://github.com/Kilo-Org/kilocode/pull/13102) [`f4cba05`](https://github.com/Kilo-Org/kilocode/commit/f4cba053a0ad9ef177f4c1c2ec845420e959f063) Thanks [@quanzhuo](https://github.com/quanzhuo)! - Preserve the selected session agent when sending headless prompts without an explicit agent. + +- [#12388](https://github.com/Kilo-Org/kilocode/pull/12388) [`c8e9c3b`](https://github.com/Kilo-Org/kilocode/commit/c8e9c3bf8f942c4ad40678bbc19ec541ef5c5928) Thanks [@rakshith1928](https://github.com/rakshith1928)! - Surface the underlying reason when `kilo --cloud-fork` fails to import a cloud session (HTTP status, server message, or fetch error) in both the user-visible message and the DEBUG log stream. + +- [#13100](https://github.com/Kilo-Org/kilocode/pull/13100) [`753d560`](https://github.com/Kilo-Org/kilocode/commit/753d5609859f2b646c404392e71ca048714f61dd) - Support structured AWS access keys and Google Cloud service-account JSON when connecting Bedrock and Vertex AI in VS Code. + +- [#13108](https://github.com/Kilo-Org/kilocode/pull/13108) [`738163b`](https://github.com/Kilo-Org/kilocode/commit/738163bb1255ec9eb1b56c2c5fc1d7ea5fc3d3d4) - Show the real commit-message generation error instead of a generic "check server logs" toast. + +- [#12373](https://github.com/Kilo-Org/kilocode/pull/12373) [`3a99f36`](https://github.com/Kilo-Org/kilocode/commit/3a99f36d96d316f03d481d7b120b9f1aaca243f1) Thanks [@mvanhorn](https://github.com/mvanhorn)! - Fix subagent permission errors that referenced phantom deny rules and blocked commands the subagent's own config explicitly allowed. A read-only or delegating agent's `readOnlyBash` allowlist is no longer projected onto a writable subagent as a bash ceiling, so a delegated subagent can run its own allowed commands (e.g. `git status`). Edit, notebook, and MCP denials are still inherited as hard ceilings. + +- [#13107](https://github.com/Kilo-Org/kilocode/pull/13107) [`746fa97`](https://github.com/Kilo-Org/kilocode/commit/746fa974ecaa11de5e587f6d0b0067aa4872d291) - Stop models.dev catalog refresh errors from overwriting the TUI prompt. + +- Changes from opencode v1.17.13 to v1.18.0 upstream: + - Core Improvements: Added a code mode MCP adapter for running confined orchestration scripts against connected MCP tools. + - Core Improvements: Hid the `execute` tool unless code mode is enabled. + - Core Improvements: Add a model-specific system prompt for Meta Muse Spark. + - Core Improvements: Updated Azure AI support for GPT-5.6. + - Core Bugfixes: Fixed paginated MCP tool catalogs losing tool metadata and output schema validation. + - Core Bugfixes: Preserved low reasoning effort for OpenRouter small-model variants instead of disabling it. + - Core Bugfixes: Fixed GitHub Copilot model routing to honor each model's advertised chat or responses endpoint. + - Core Bugfixes: Fixed session lists to match equivalent instance directories reliably. + - Core Bugfixes: Fixed Cerebras reasoning replay so earlier assistant reasoning is sent back in the provider-supported field. + - Core Bugfixes: Better classify Z.ai context-window overflow errors so oversized requests surface the right failure mode (@fengjikui) + - Core Bugfixes: Handle unavailable config directories more gracefully when reading config files + - Core Bugfixes: Exposed reasoning effort variants for Grok models. + - Core Bugfixes: Improved xAI prompt cache routing and PDF file support in Responses models. + - Core Bugfixes: Improved Meta model handling for reasoning variants and provider requests. + - Core Bugfixes: Prevent crashes and bad pricing data when GitHub Copilot returns models with a zero billing batch size. + - Core Bugfixes: Supported OpenAI pro reasoning mode. + - Core Bugfixes: Disabled response storage by default for xAI Responses. (@geraint0923) + - Core Bugfixes: Added OAuth support for Luna Responses Lite. + - Core Bugfixes: Switched to another available org after logging out in the console. + - Core Bugfixes: Used Codex context limits for GPT-5.6 over OAuth. (@nabilfreeman) + - Core Bugfixes: Removed an obsolete Codex workaround that could interfere with OpenAI Luna Responses Lite requests. + - TUI Bugfixes: Fixed spinner registration so loading indicators keep rendering across TUI surfaces. + - TUI Bugfixes: Forwarded CLI environment variables to the TUI worker. + +- Adopt upstream improvements from v1.18.1 through v1.18.13, including model compatibility, MCP reliability, and TUI enhancements. + +- [#13104](https://github.com/Kilo-Org/kilocode/pull/13104) [`9b01d97`](https://github.com/Kilo-Org/kilocode/commit/9b01d97cf336e42a33ede75d44232c907c022938) - Preserve workspace restoration outcomes when reverting fresh VS Code sessions. + +- [#13067](https://github.com/Kilo-Org/kilocode/pull/13067) [`2c2b0a2`](https://github.com/Kilo-Org/kilocode/commit/2c2b0a2ffa563ad8883fbb52260e8c3204406e4f) - Stop startup from crashing on a database migration that another Kilo process already applied + +- [#13114](https://github.com/Kilo-Org/kilocode/pull/13114) [`b5f5d9f`](https://github.com/Kilo-Org/kilocode/commit/b5f5d9f22aac812468c1d8e8cea7b9cf7f04da7a) - Stop leftover toast titles from appearing when installing a TUI update. + ## 7.4.21 ### Minor Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0aedbf51da7..4a6b8c9f666 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.21", + "version": "7.4.22", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -8,7 +8,7 @@ "scripts": { "typecheck": "tsgo --noEmit", "test": "bun run script/test-runner.ts", - "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", + "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip --shards 4", "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", "build": "bun run script/build.ts", diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 5395a812f55..57251f2f4f7 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -1 +1,51 @@ +// kilocode_change start - `--shards N` fans a pass out across N child processes, each +// running `--shard i/N`. Children are fully isolated: the exerciser keys its database +// and global root by PID (test/server/httpapi-exercise/environment.ts), so shards never +// share SQLite files or on-disk state. Route coverage (missing/extra) stays correct +// because every child checks it against the full scenario list. +const args = Bun.argv.slice(2) +const shardsIndex = args.indexOf("--shards") +const shards = shardsIndex === -1 ? 0 : Number(args[shardsIndex + 1]) + +if (shardsIndex !== -1 && (!Number.isInteger(shards) || shards < 1)) { + console.error(`invalid --shards ${args[shardsIndex + 1]}, expected a positive integer`) + process.exit(1) +} + +if (shards > 1) { + const passthrough = args.filter((_, index) => index !== shardsIndex && index !== shardsIndex + 1) + // Children must key their database and global root by their own PID. If the parent's + // environment pins these (KILO_HTTPAPI_EXERCISE_*), every child would inherit the same + // SQLite file and XDG root and shards would collide — strip them so isolation holds. + const env = { ...process.env } + delete env["KILO_HTTPAPI_EXERCISE_DB"] + delete env["KILO_HTTPAPI_EXERCISE_GLOBAL"] + const children = Array.from({ length: shards }, (_, index) => + Bun.spawn( + [process.execPath, "run", import.meta.path, ...passthrough, "--shard", `${index}/${shards}`], + { stdout: "pipe", stderr: "pipe", env }, + ), + ) + const relay = async (stream: ReadableStream, tag: string, sink: NodeJS.WriteStream) => { + const decoder = new TextDecoder() + let buffer = "" + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + for (const line of lines) sink.write(`${tag} ${line}\n`) + } + if (buffer) sink.write(`${tag} ${buffer}\n`) + } + const codes = await Promise.all( + children.map(async (child, index) => { + const tag = `[shard ${index + 1}/${shards}]` + await Promise.all([relay(child.stdout, tag, process.stdout), relay(child.stderr, tag, process.stderr)]) + return child.exited + }), + ) + process.exit(codes.every((code) => code === 0) ? 0 : 1) +} +// kilocode_change end + await import("../test/server/httpapi-exercise/index") diff --git a/packages/opencode/script/kilocode/test-durations.json b/packages/opencode/script/kilocode/test-durations.json new file mode 100644 index 00000000000..14e30574f1a --- /dev/null +++ b/packages/opencode/script/kilocode/test-durations.json @@ -0,0 +1,321 @@ +{ + "acp/config-option.test.ts": 1400, + "acp/content.test.ts": 1400, + "acp/directory.test.ts": 1400, + "acp/error.test.ts": 1200, + "acp/event.test.ts": 1500, + "acp/permission.test.ts": 1100, + "acp/service-session.test.ts": 1300, + "acp/session.test.ts": 1300, + "acp/tool.test.ts": 1200, + "acp/usage.test.ts": 1200, + "agent/agent.test.ts": 16000, + "agent/plan-mode-subagent-bypass.test.ts": 2900, + "agent/plugin-agent-regression.test.ts": 1800, + "auth/auth.test.ts": 1400, + "background/job.test.ts": 1700, + "bun/registry.test.ts": 1000, + "cli/account.test.ts": 1000, + "cli/acp/config-options.test.ts": 11500, + "cli/acp/initialize-auth.test.ts": 6100, + "cli/acp/lifecycle.test.ts": 15000, + "cli/acp/prompt-content.test.ts": 4800, + "cli/acp/skills.test.ts": 3400, + "cli/auto-mode.test.ts": 1100, + "cli/bin-kilo.test.ts": 1000, + "cli/cmd/tui/attention.test.ts": 1400, + "cli/effect-cmd-instance-als.test.ts": 3500, + "cli/error.test.ts": 1200, + "cli/github-action.test.ts": 1300, + "cli/github-remote.test.ts": 1300, + "cli/help/help-snapshots.test.ts": 8000, + "cli/import.test.ts": 1100, + "cli/mcp-add.test.ts": 5600, + "cli/plugin-auth-picker.test.ts": 1100, + "cli/pr.test.ts": 1000, + "cli/run/entry.body.test.ts": 1000, + "cli/run/footer.menu.test.ts": 1200, + "cli/run/footer.view.test.tsx": 2000, + "cli/run/footer.width.test.ts": 1000, + "cli/run/permission.shared.test.ts": 1100, + "cli/run/prompt.editor.test.ts": 1000, + "cli/run/prompt.shared.test.ts": 1200, + "cli/run/question.shared.test.ts": 1300, + "cli/run/run-process.test.ts": 169700, + "cli/run/runtime.boot.test.ts": 1400, + "cli/run/runtime.queue.test.ts": 1400, + "cli/run/runtime.stdin.test.ts": 1100, + "cli/run/runtime.test.ts": 1600, + "cli/run/scrollback.surface.test.ts": 2000, + "cli/run/session-data.test.ts": 1000, + "cli/run/session-replay.test.ts": 1200, + "cli/run/session.shared.test.ts": 1300, + "cli/run/stream.test.ts": 1000, + "cli/run/stream.transport.test.ts": 1800, + "cli/run/subagent-data.test.ts": 1000, + "cli/run/theme.test.ts": 1800, + "cli/run/variant.shared.test.ts": 1500, + "cli/serve/serve-process.test.ts": 4300, + "cli/smokes/read-only.test.ts": 12300, + "cli/tui/attach.test.ts": 1100, + "cli/tui/editor-context-zed.test.ts": 1300, + "cli/tui/editor-context.test.tsx": 1800, + "cli/tui/markdown.test.ts": 1100, + "cli/tui/plugin-add.test.ts": 2100, + "cli/tui/plugin-install.test.ts": 1900, + "cli/tui/plugin-lifecycle.test.ts": 2100, + "cli/tui/plugin-loader-entrypoint.test.ts": 2200, + "cli/tui/plugin-loader-pure.test.ts": 2100, + "cli/tui/plugin-loader.test.ts": 2200, + "cli/tui/plugin-toggle.test.ts": 2100, + "cli/tui/thread.test.ts": 5700, + "control-plane/adapters.test.ts": 1300, + "control-plane/workspace.test.ts": 22100, + "filesystem/filesystem.test.ts": 1400, + "fixture/fixture.test.ts": 1700, + "git/git.test.ts": 4900, + "ide/ide.test.ts": 1100, + "kilocode/acp/agent-interface.test.ts": 1200, + "kilocode/acp/permission.test.ts": 1100, + "kilocode/background-process-shutdown.test.ts": 1500, + "kilocode/background-process.test.ts": 46800, + "kilocode/cli-shutdown.test.ts": 400, + "kilocode/cli/cmd/console.test.ts": 1100, + "kilocode/cli/cmd/mcp.test.ts": 1000, + "kilocode/cli/cmd/remote.test.ts": 1200, + "kilocode/cli/cmd/run-message.test.ts": 1100, + "kilocode/cli/cmd/run-terminal.test.ts": 1100, + "kilocode/cli/cmd/run/interactive-terminal.test.ts": 1000, + "kilocode/cli/cmd/serve.test.ts": 3500, + "kilocode/cli/cmd/tui-worktree.test.ts": 2300, + "kilocode/cli/cmd/tui/attention.test.ts": 1400, + "kilocode/cli/cmd/tui/context/tui-config.test.ts": 1400, + "kilocode/cli/cmd/tui/cost-alert.test.ts": 1000, + "kilocode/cli/cmd/tui/memory-command.test.ts": 1600, + "kilocode/cli/cmd/tui/memory-status.test.tsx": 1400, + "kilocode/cli/cmd/tui/model-usage.test.ts": 1300, + "kilocode/cli/cmd/tui/prompt/vim.test.ts": 1000, + "kilocode/cli/cmd/tui/sidebar-usage-layout.test.tsx": 1200, + "kilocode/cli/dev-setup.test.ts": 1300, + "kilocode/cli/direct-mode-branding.test.ts": 1100, + "kilocode/cli/error.test.ts": 1100, + "kilocode/cli/install-artifact.test.ts": 2000, + "kilocode/cli/profile.test.ts": 1100, + "kilocode/cli/roll-call.test.ts": 2500, + "kilocode/cli/run/variant.test.ts": 1100, + "kilocode/cli/tui/remote-exit-bridge.test.ts": 1100, + "kilocode/cli/tui/remote-exit-worker.test.ts": 1000, + "kilocode/cli/tui/thread.test.ts": 7300, + "kilocode/cloud-session.test.ts": 1200, + "kilocode/command/override.test.ts": 1000, + "kilocode/commit-message/generate.test.ts": 1200, + "kilocode/commit-message/git-context.test.ts": 8500, + "kilocode/database/sqlite-error.test.ts": 1000, + "kilocode/event-service/client.test.ts": 1400, + "kilocode/indexing-startup.test.ts": 32400, + "kilocode/installation/upgrade.test.ts": 1300, + "kilocode/instance-vcs-watcher.test.ts": 3600, + "kilocode/kilo-sessions.test.ts": 36700, + "kilocode/lancedb-runtime.test.ts": 1100, + "kilocode/local-model.test.ts": 2000, + "kilocode/permission/agent-manager-prompt.test.ts": 1100, + "kilocode/permission/config-paths.test.ts": 1700, + "kilocode/permission/deny-provenance.test.ts": 1600, + "kilocode/permission/env-read.test.ts": 2300, + "kilocode/permission/external-directory-allow.test.ts": 8400, + "kilocode/permission/next.always-rules.test.ts": 15100, + "kilocode/permission/next.reply-http.test.ts": 4300, + "kilocode/permission/next.reply-routing.test.ts": 4200, + "kilocode/permission/permission-origins.test.ts": 1300, + "kilocode/permission/provenance.test.ts": 1000, + "kilocode/permission/skill-shell.test.ts": 4600, + "kilocode/run-auto.test.ts": 1300, + "kilocode/run-network.test.ts": 1700, + "kilocode/sandbox/config-network.test.ts": 1300, + "kilocode/sandbox/http-tools.test.ts": 1800, + "kilocode/sandbox/macos-confinement.test.ts": 11100, + "kilocode/sandbox/network.test.ts": 1200, + "kilocode/sandbox/policy.test.ts": 1700, + "kilocode/sandbox/sdk-config.test.ts": 1100, + "kilocode/sandbox/session-tools.test.ts": 4000, + "kilocode/sandbox/session.test.ts": 11400, + "kilocode/sandbox/shell-network.test.ts": 3900, + "kilocode/sandbox/state.test.ts": 5800, + "kilocode/sandbox/tui.test.ts": 1200, + "kilocode/server/agent-builder.test.ts": 4900, + "kilocode/server/cloud-session-import.test.ts": 7700, + "kilocode/server/commit-message-no-changes.test.ts": 2700, + "kilocode/server/config-model-state.test.ts": 2900, + "kilocode/server/config-overlay.test.ts": 60600, + "kilocode/server/config-rules.test.ts": 2600, + "kilocode/server/config-sources.test.ts": 3000, + "kilocode/server/fork-routing.test.ts": 1100, + "kilocode/server/httpapi-experimental.test.ts": 3200, + "kilocode/server/httpapi-global-sse.test.ts": 1500, + "kilocode/server/httpapi-kilo-edit.test.ts": 5800, + "kilocode/server/httpapi-memory.test.ts": 4500, + "kilocode/server/httpapi-public.test.ts": 2700, + "kilocode/server/kilo-gateway-statuses.test.ts": 1600, + "kilocode/server/listener-runtime.test.ts": 3300, + "kilocode/server/permission-allow-everything.test.ts": 4800, + "kilocode/server/prompt-training-model-filter.test.ts": 2900, + "kilocode/server/provider-auth-lifecycle.test.ts": 1100, + "kilocode/server/tui-config.test.ts": 5200, + "kilocode/server/worktree-list.test.ts": 3000, + "kilocode/session-compaction-chunks.test.ts": 3000, + "kilocode/session-fork-remap.test.ts": 9700, + "kilocode/session-processor-retry-limit.test.ts": 4900, + "kilocode/session-prompt-queue.test.ts": 31100, + "kilocode/session-prompt-steering.test.ts": 7200, + "kilocode/session-share.test.ts": 1500, + "kilocode/shell/shell.test.ts": 1200, + "kilocode/skill/discovery-validate.test.ts": 1100, + "kilocode/telemetry/feedback.test.ts": 1100, + "lsp/client.test.ts": 4100, + "lsp/index.test.ts": 3000, + "lsp/jdtls-root.test.ts": 1200, + "lsp/launch.test.ts": 1200, + "lsp/lifecycle.test.ts": 2700, + "mcp/auth.test.ts": 1200, + "mcp/catalog.test.ts": 1100, + "mcp/headers.test.ts": 2300, + "mcp/lifecycle.test.ts": 7500, + "mcp/oauth-auto-connect.test.ts": 3200, + "mcp/oauth-callback.test.ts": 1200, + "mcp/oauth-provider.test.ts": 1200, + "mcp/session-recovery.test.ts": 1300, + "mcp/transport.test.ts": 1000, + "permission-task.test.ts": 7200, + "permission/arity.test.ts": 1000, + "permission/next.test.ts": 12300, + "permission/next.toConfig.test.ts": 1300, + "plugin/auth-override.test.ts": 3000, + "plugin/cloudflare.test.ts": 1000, + "plugin/codex.test.ts": 1000, + "plugin/github-copilot-models.test.ts": 1400, + "plugin/install-concurrency.test.ts": 4600, + "plugin/install.test.ts": 1500, + "plugin/loader-shared.test.ts": 2600, + "plugin/meta.test.ts": 3400, + "plugin/modal-models.test.ts": 1100, + "plugin/openai-rollout.test.ts": 1000, + "plugin/openai-ws.test.ts": 3600, + "plugin/shared.test.ts": 1000, + "plugin/snowflake-cortex.test.ts": 1300, + "plugin/trigger.test.ts": 1600, + "plugin/workspace-adapter.test.ts": 1800, + "plugin/xai.test.ts": 1600, + "project/instance-bootstrap.test.ts": 6500, + "project/instance.test.ts": 9300, + "project/migrate-global.test.ts": 3600, + "project/project-directory.test.ts": 5600, + "project/project.test.ts": 20900, + "project/vcs.test.ts": 11800, + "project/worktree-remove.test.ts": 4100, + "project/worktree.test.ts": 25000, + "provider/amazon-bedrock.test.ts": 9000, + "provider/cf-ai-gateway-e2e.test.ts": 1100, + "provider/digitalocean.test.ts": 5000, + "provider/gitlab-duo.test.ts": 1000, + "provider/header-timeout.test.ts": 6600, + "provider/provider.test.ts": 52000, + "server/auth.test.ts": 1200, + "server/experimental-session-list.test.ts": 13700, + "server/global-session-list.test.ts": 3300, + "server/httpapi-authorization.test.ts": 1400, + "server/httpapi-compression.test.ts": 3300, + "server/httpapi-config.test.ts": 2300, + "server/httpapi-control-plane.test.ts": 1300, + "server/httpapi-cors-vary.test.ts": 2200, + "server/httpapi-cors.test.ts": 2900, + "server/httpapi-error-middleware.test.ts": 1300, + "server/httpapi-event.test.ts": 8500, + "server/httpapi-experimental.test.ts": 10300, + "server/httpapi-file.test.ts": 4600, + "server/httpapi-global.test.ts": 1200, + "server/httpapi-instance-context.test.ts": 12300, + "server/httpapi-instance-route-auth.test.ts": 4400, + "server/httpapi-instance.test.ts": 13900, + "server/httpapi-listen.test.ts": 22600, + "server/httpapi-mcp-oauth.test.ts": 1100, + "server/httpapi-mcp.test.ts": 3500, + "server/httpapi-mdns.test.ts": 2100, + "server/httpapi-promptasync-context.test.ts": 5200, + "server/httpapi-provider.test.ts": 4700, + "server/httpapi-pty.test.ts": 21700, + "server/httpapi-public-openapi.test.ts": 2000, + "server/httpapi-query-schema-drift.test.ts": 2500, + "server/httpapi-reference.test.ts": 3400, + "server/httpapi-schema-error-body.test.ts": 7800, + "server/httpapi-sdk.test.ts": 18600, + "server/httpapi-session.test.ts": 31800, + "server/httpapi-sync.test.ts": 4600, + "server/httpapi-ui.test.ts": 1500, + "server/httpapi-v2-location.test.ts": 3700, + "server/httpapi-v2-pty.test.ts": 12000, + "server/httpapi-workspace-routing.test.ts": 6100, + "server/httpapi-workspace.test.ts": 16100, + "server/negative-tokens-regression.test.ts": 2700, + "server/project-copy.test.ts": 4600, + "server/project-init-git.test.ts": 4400, + "server/proxy-util.test.ts": 1100, + "server/sdk-error-shape.test.ts": 2700, + "server/sdk-v1-smoke.test.ts": 5600, + "server/session-actions.test.ts": 5800, + "server/session-diff-missing-patch.test.ts": 4300, + "server/session-list.test.ts": 10000, + "server/session-messages.test.ts": 10500, + "server/session-select.test.ts": 5600, + "server/workspace-proxy.test.ts": 1500, + "server/workspace-routing.test.ts": 1200, + "server/worktree-endpoint-repro.test.ts": 22300, + "session/compaction.test.ts": 29400, + "session/instruction.test.ts": 1700, + "session/llm-native-recorded.test.ts": 3200, + "session/llm-native.test.ts": 1700, + "session/llm.test.ts": 6600, + "session/message-v2.test.ts": 1400, + "session/messages-pagination.test.ts": 2700, + "session/network.test.ts": 6300, + "session/processor-effect.test.ts": 22000, + "session/prompt.test.ts": 104600, + "session/retry.test.ts": 9400, + "session/revert-compact.test.ts": 19600, + "session/schema-decoding.test.ts": 1300, + "session/session-schema.test.ts": 1000, + "session/session.test.ts": 2600, + "session/shell-v2.test.ts": 1200, + "session/snapshot-tool-race.test.ts": 8600, + "session/structured-output-integration.test.ts": 1300, + "session/structured-output.test.ts": 1300, + "session/system.test.ts": 1100, + "skill/discovery.test.ts": 1400, + "skill/skill.test.ts": 18800, + "snapshot/snapshot.test.ts": 139700, + "storage/db.test.ts": 1100, + "storage/storage.test.ts": 2200, + "tool/apply_patch.test.ts": 8300, + "tool/code-mode-integration.test.ts": 1600, + "tool/code-mode.test.ts": 2100, + "tool/diagnostics-filter.test.ts": 1200, + "tool/edit.test.ts": 7800, + "tool/external-directory.test.ts": 1500, + "tool/glob.test.ts": 2000, + "tool/grep.test.ts": 2200, + "tool/lsp.test.ts": 9300, + "tool/parameters.test.ts": 1300, + "tool/question.test.ts": 2400, + "tool/read.test.ts": 12900, + "tool/recall.test.ts": 6400, + "tool/registry.test.ts": 5300, + "tool/shell.test.ts": 12700, + "tool/skill.test.ts": 5800, + "tool/suggest.test.ts": 1000, + "tool/task.test.ts": 12400, + "tool/tool-define.test.ts": 1400, + "tool/truncation.test.ts": 2500, + "tool/webfetch.test.ts": 2700, + "tool/websearch.test.ts": 1200, + "tool/write.test.ts": 6700, + "v2/session-message-updater.test.ts": 1100 +} \ No newline at end of file diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts index c27ce536102..2a2b295c3ad 100644 --- a/packages/opencode/script/test-runner.ts +++ b/packages/opencode/script/test-runner.ts @@ -29,12 +29,13 @@ if (argv.includes("--help") || argv.includes("-h")) { "", "Options:", " --ci Enable JUnit XML output to .artifacts/unit/junit.xml", - " --concurrency Max parallel processes (default: min(4, CPU count))", + " --concurrency Max parallel processes (default: min(4, CPU count), env: KILO_TEST_CONCURRENCY)", " --timeout Per-test timeout passed to bun test (default: 60000)", - " --file-timeout Per-file process timeout (default: 300000)", + " --file-timeout Per-file process timeout (default: 300000, env: KILO_TEST_FILE_TIMEOUT)", " --retries Extra attempts for failing files (default: 1)", " --profile Run a curated test profile (env: KILO_TEST_PROFILE)", " --shard Run one balanced file shard (env: KILO_TEST_SHARD)", + " --update-durations After a full run, refresh script/kilocode/test-durations.json", " --bail Stop on first failure", " --dots Show compact dot progress", " --verbose Show full output for every file", @@ -68,14 +69,45 @@ function text(name: string) { const ci = argv.includes("--ci") const bail = argv.includes("--bail") +const updateDurations = argv.includes("--update-durations") // kilocode_change const verbose = argv.includes("--verbose") const dots = !verbose && (ci || argv.includes("--dots")) // Cap concurrency at 4 even on bigger runners: the bottleneck is shared // resources (ports, global filesystem like ~/.local/share/kilo), not CPU. // Eight parallel processes was triggering port/FS races, not going faster. -const concurrency = opt("concurrency", Math.min(4, os.cpus().length)) +// kilocode_change start - allow CI to lower concurrency via env. On the 4-vCPU +// Windows runner, the default (min(4, cpus)=4) oversubscribes: 4 heavy real-server +// test files share 4 vCPUs (~1 each) and blow their per-test timeouts. +// `KILO_TEST_CONCURRENCY` lets the workflow throttle Windows without affecting the +// local default. An explicit `--concurrency` flag wins. +const concurrencyEnv = (() => { + const raw = process.env.KILO_TEST_CONCURRENCY?.trim() + if (!raw) return undefined + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 1) { + console.error(`Invalid KILO_TEST_CONCURRENCY "${raw}"; expected a positive integer`) + process.exit(2) + } + return value +})() +const concurrency = opt("concurrency", concurrencyEnv ?? Math.min(4, os.cpus().length)) +// kilocode_change end const timeout = opt("timeout", 60000) -const deadline = opt("file-timeout", 300000) +// kilocode_change start - allow CI to raise the per-file kill deadline via env. On Windows, +// heavy real-server files (e.g. config-overlay) legitimately run ~270s serially, only ~30s +// under the 300s default; raising it there prevents a slow-but-healthy run from being killed. +const fileTimeoutEnv = (() => { + const raw = process.env.KILO_TEST_FILE_TIMEOUT?.trim() + if (!raw) return undefined + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 1) { + console.error(`Invalid KILO_TEST_FILE_TIMEOUT "${raw}"; expected a positive integer (ms)`) + process.exit(2) + } + return value +})() +const deadline = opt("file-timeout", fileTimeoutEnv ?? 300000) +// kilocode_change end const retries = opt("retries", 1) const flag = text("profile") const env = process.env.KILO_TEST_PROFILE?.trim() || undefined @@ -156,8 +188,208 @@ if (shard && shard.total > candidates.length) { console.error(`Test shard count ${shard.total} exceeds selected file count ${candidates.length}`) process.exit(2) } -const weight = (file: string) => Bun.file(path.join(root, "test", file)).size -const files = shard ? TestShard.split(candidates, weight, shard.total)[shard.index - 1] : candidates +// kilocode_change start - shard by estimated DURATION, not file size. File size is a poor +// proxy: run-process.test.ts is ~7 KB but ~230s, while config-overlay is the single slowest +// file — under size-weighting both landed in the same shard, stacking the two heaviest files. +// DURATION_HINTS are max observed per-file durations (ms) from real Windows CI runs; the LPT +// splitter places the highest-weight files first, so hinted heavy files get spread across +// distinct shards. Unhinted files fall back to size (a fine proxy among the fast majority); +// hint values (tens of thousands of ms) dominate byte sizes, so heavy files always sort first. +// Refresh these from observed CI durations when the suite changes materially. +const DURATION_HINTS: Record = { + "kilocode/server/config-overlay.test.ts": 270_000, + "cli/run/run-process.test.ts": 233_000, + "snapshot/snapshot.test.ts": 165_000, + "session/prompt.test.ts": 128_000, + "tool/shell.test.ts": 95_000, + "kilocode/background-process.test.ts": 94_000, + "provider/provider.test.ts": 90_000, + "kilocode/indexing-startup.test.ts": 88_000, + "kilocode/daemon.test.ts": 65_000, + "tool/task.test.ts": 64_000, +} +// Measured per-file durations (ms) from a full local run; refresh with +// `bun run script/test-runner.ts --update-durations`. Relative order is what LPT needs, +// so a macOS measurement balances Windows shards fine. Hints above still win: they are +// Windows-observed maxima. +const measuredDurations: Record = await Bun.file( + path.join(root, "script", "kilocode", "test-durations.json"), +) + .json() + .catch(() => ({})) +// Memoized: weight() runs inside sort comparators (TestShard.order/split), and the +// byte-size fallback is a blocking stat syscall — thousands of repeats without a cache. +const weightCache = new Map() +const weight = (file: string) => { + const cached = weightCache.get(file) + if (cached !== undefined) return cached + const value = DURATION_HINTS[file] ?? measuredDurations[file] ?? Bun.file(path.join(root, "test", file)).size + weightCache.set(file, value) + return value +} +// kilocode_change end + +// kilocode_change start - fast tier: run isolation-safe test files in ONE shared `bun test` +// process instead of one process each. Per-file processes exist to contain cross-test state +// (disk DBs, singletons, native handles); the directories below are verified to run together +// cleanly in a single pass (empirically: all pass in one process). This trades ~1s of process +// boot + TS compile per file for a single boot, the dominant cost for these small fast files. +// The batch enters shard splitting as one pseudo-file with a duration-scale weight, so LPT +// places it like any other heavy file and exactly one shard executes it. Directories are +// batched by default; files that cannot share a process are excluded by BATCH_EXCLUDES or +// demoted automatically by the unsafe-marker scan below. +// Deliberately excludes directories whose runtime is real I/O work (git/, server/, session/, +// project/, provider/, ...): those parallelize well across the per-file worker pool, while a +// batch runs its members sequentially. The tier is for files where boot cost dominates. +// Several independent batches (instead of one) keep each batch short enough to schedule +// like a normal heavy file, and let LPT spread them across shards and worker slots. +// Batch weights are computed from member durations, never hand-maintained. +const FAST_TIERS: Record = { + "fast-tier-core": [ + "account/", + "config/", + "effect/", + "event-manifest.test.ts", + "format/", + "image/", + "installation/", + "patch/", + "provider/model-status.test.ts", + "provider/transform.test.ts", + "question/", + "share/", + "suggestion/", + "util/", + ], + "fast-tier-kilocode": [ + "kilocode/config/", + "kilocode/memory/", + // kilocode/permission/ stays per-file: permission-origins asserts the merged config has + // no global-scope keys, so it cannot share an XDG root with tests that write global config. + "kilocode/presence/", + "kilocode/project/", + "kilocode/provider/", + "kilocode/skills/", + "kilocode/storage/", + "kilocode/suggestion/", + "kilocode/tui/", + "kilocode/util/", + ], + "fast-tier-kilocode-sessions": ["kilocode/session-export/", "kilocode/session/", "kilocode/sessions/"], + "fast-tier-kilocode-tools": ["kilocode/anaconda-desktop/", "kilocode/cloud/", "kilocode/tool/"], + "fast-tier-cli": ["cli/"], + "fast-tier-misc": ["acp/", "auth/", "bun/", "filesystem/", "ide/", "lsp/", "mcp/", "plugin/", "storage/", "v2/"], + "fast-tier-tool": ["tool/"], +} +// Files that must run alone, never in a shared batch, for reasons a source scan cannot +// detect: real subprocesses, fs watchers, and wall-clock stall simulations are all +// timing-sensitive under batch CPU contention. Same entry semantics as FAST_TIERS +// (".ts" = exact file, otherwise directory prefix). Files with *detectable* process-wide +// markers (mock.module, AppRuntime.dispose, global fetch spies) do not need listing here: +// the batch builder scans member sources and demotes them to per-file automatically. +const BATCH_EXCLUDES = [ + "cli/run/", // spawns real non-interactive runs (SIGINT/daemon timing) + "kilocode/background-process.test.ts", + "kilocode/daemon.test.ts", // spawns real daemon subprocesses, races wall-clock deadlines + "kilocode/instance-vcs-watcher.test.ts", + "kilocode/issue-8656-stall.test.ts", + // Heavy real-work files: a batch runs members sequentially, so files whose runtime is + // dominated by real execution (not process boot) parallelize better in their own process. + "tool/shell.test.ts", + "tool/task.test.ts", +] +// Entry semantics shared by FAST_TIERS and BATCH_EXCLUDES: ".ts" = exact file, else prefix. +const matchesEntry = (file: string, entry: string) => (entry.endsWith(".ts") ? file === entry : file.startsWith(entry)) +const isBatchExcluded = (file: string) => BATCH_EXCLUDES.some((entry) => matchesEntry(file, entry)) +// 8 batches (~24 files each) keep the heaviest single work item small enough for +// LPT to pack shards evenly; fewer, bigger batches set a floor under the slowest shard. +const KILOCODE_ROOT_TIERS = 8 +const kilocodeRootTier = (file: string) => { + if (!file.startsWith("kilocode/")) return undefined + if (file.slice("kilocode/".length).includes("/")) return undefined + let hash = 0 + for (let i = 0; i < file.length; i++) hash = (hash * 31 + file.charCodeAt(i)) | 0 + return `fast-tier-kilocode-root-${Math.abs(hash) % KILOCODE_ROOT_TIERS}` +} +const tierOf = (file: string) => { + if (isBatchExcluded(file)) return undefined + for (const [name, entries] of Object.entries(FAST_TIERS)) { + if (entries.some((entry) => matchesEntry(file, entry))) return name + } + return kilocodeRootTier(file) +} +const batches = new Map() +// --update-durations disables batching so every file runs (and is measured) individually; +// batched members otherwise never appear in results and their entries would rot. +if (patterns.length === 0 && !profile && !updateDurations) { + for (const file of candidates) { + const tier = tierOf(file) + if (!tier) continue + const members = batches.get(tier) ?? [] + members.push(file) + batches.set(tier, members) + } + + // A batch shares one process, so process-wide mutations poison every later file in it. + // Scan member sources for the known-unsafe markers and demote matches to per-file runs: + // bun's mock.module is process-wide and permanent, AppRuntime.dispose() kills the shared + // runtime, and global-fetch spies observe batch-mates' traffic. A developer adding such + // a test anywhere keeps a green suite; the file just does not share a process. + // Limitation: only the test file's own source is scanned — a marker hidden in an + // imported helper is invisible; batch-only failures that vanish per-file point there. + // Markers: bun module mocks, disposal of the shared app runtime, spies on true globals, + // and module-scope env writes (column 0 — env set inside a test body is indented and + // typically restored; a load-time write leaks into every batch-mate's import snapshot). + const unsafe = [ + /\bmock\.module\s*\(/, + /\bAppRuntime\.dispose\s*\(/, + /\bspyOn\s*\(\s*globalThis\b/, + /^process\.env[.[]/m, + ] + const demoted = new Set() + const members = [...batches.values()].flat() + // Bounded chunks: an unbounded Promise.all over ~440 files can exhaust low soft + // fd limits (macOS shells commonly default to 256) before a single test runs. + for (let i = 0; i < members.length; i += 64) { + await Promise.all( + members.slice(i, i + 64).map(async (member) => { + const source = await Bun.file(path.join(root, "test", member)).text() + if (unsafe.some((pattern) => pattern.test(source))) demoted.add(member) + }), + ) + } + if (demoted.size > 0) { + console.log( + `Fast tier: ${demoted.size} file(s) use process-wide mocks/disposal/spies and run per-file instead:\n` + + [...demoted].map((file) => `- ${file}`).join("\n"), + ) + for (const [name, members] of batches) + batches.set( + name, + members.filter((member) => !demoted.has(member)), + ) + } + for (const [name, members] of batches) if (members.length < 2) batches.delete(name) +} +const batched = new Set([...batches.values()].flat()) +const shardInput = [...batches.keys(), ...candidates.filter((file) => !batched.has(file))] +// A batch runs its members sequentially, so its weight is the sum of member durations +// (hint > measured > a typical boot-dominated file) plus one process boot. Computed, not +// hand-maintained: stale per-tier constants systematically under-weighted heavy batches. +const TYPICAL_BATCHED_FILE_MS = 1_500 +const BATCH_BOOT_MS = 3_000 +const batchWeights = new Map( + [...batches.entries()].map(([name, members]) => [ + name, + members.reduce( + (total, member) => total + (DURATION_HINTS[member] ?? measuredDurations[member] ?? TYPICAL_BATCHED_FILE_MS), + BATCH_BOOT_MS, + ), + ]), +) +const shardWeight = (file: string) => batchWeights.get(file) ?? weight(file) +const files = shard ? TestShard.split(shardInput, shardWeight, shard.total)[shard.index - 1] : shardInput +// kilocode_change end if (files.length === 0) { console.log("No test files found") @@ -176,6 +408,7 @@ type Result = { stderr: string duration: number timedout: boolean + deadline: number // kilocode_change - the kill deadline actually applied (batches get a roomier one) attempts: number } @@ -189,9 +422,7 @@ const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : "" if (ci) await fs.mkdir(xmldir, { recursive: true }) // kilocode_change start const supplied = process.env[TestCli.ENV] -const built = supplied - ? { binary: supplied, dir: undefined } - : { binary: await TestCli.build(root), dir: undefined } +const built = supplied ? { binary: supplied, dir: undefined } : { binary: await TestCli.build(root), dir: undefined } async function cleanBinary() { if (!built.dir) return @@ -287,8 +518,13 @@ async function terminate(proc: Proc) { // --------------------------------------------------------------------------- async function run(file: string): Promise { - const target = path.join("test", file) - const cmd = ["bun", "test", target, "--timeout", String(timeout)] + // kilocode_change start - a fast-tier pseudo-file expands to all of its batch members in + // one process; a shared pass compiles once, so it gets a roomier process deadline than a + // single file even though each member is individually fast. + const members = batches.get(file) + const targets = members ? members.map((member) => path.join("test", member)) : [path.join("test", file)] + const cmd = ["bun", "test", ...targets, "--timeout", String(timeout)] + // kilocode_change end if (ci) { const name = file.replace(/[/\\]/g, "_") + ".xml" @@ -297,6 +533,7 @@ async function run(file: string): Promise { const start = performance.now() const killed = { value: false } + const fileDeadline = members ? Math.max(deadline, 600_000) : deadline // kilocode_change const proc = Bun.spawn(cmd, { cwd: root, @@ -312,7 +549,7 @@ async function run(file: string): Promise { const stderr = drain(proc.stderr) const code = await Promise.race([ proc.exited.then((value) => ({ timedout: false, value })), - Bun.sleep(deadline).then(() => ({ timedout: true, value: -1 })), + Bun.sleep(fileDeadline).then(() => ({ timedout: true, value: -1 })), // kilocode_change ]).then(async (result) => { if (result.timedout) { killed.value = true @@ -339,6 +576,7 @@ async function run(file: string): Promise { stderr: output[1], duration: performance.now() - start, timedout: killed.value, + deadline: fileDeadline, // kilocode_change attempts: 1, } } @@ -399,7 +637,7 @@ function report(result: Result) { if (result.timedout) { console.log( - `[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}${tries}`, + `[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${result.deadline / 1000}s)`)}${tries}`, ) return } @@ -425,6 +663,12 @@ function report(result: Result) { // Parallel execution // --------------------------------------------------------------------------- +// kilocode_change start - report the batches so shard logs stay interpretable +for (const [name, members] of batches) { + if (!files.includes(name)) continue + console.log(`\nFast tier ${bold(name)}: ${bold(String(members.length))} isolation-safe files in one shared process`) +} +// kilocode_change end console.log(`\nRunning ${bold(String(files.length))} test files with concurrency ${bold(String(concurrency))}`) if (shard) console.log(`Using balanced test shard ${shard.index}/${shard.total}`) if (dots) console.log(dim(legend)) @@ -432,7 +676,30 @@ console.log() const start = performance.now() const results: Result[] = [] -const queue = TestShard.order(files, weight) +// Order by shardWeight, not weight: batch pseudo-files are not real paths, so weight() +// gives them 0 and they would start LAST — leaving one worker running a whole batch +// after everything else finished. Heaviest-first keeps the tail short. kilocode_change +const queue = TestShard.order(files, shardWeight) + +// kilocode_change start - a flaky batch names only its pseudo-file; pull the members that +// failed on the earlier attempt out of that attempt's output so annotations can attribute +// the flake to real files. bun prints a "test/:" heading before each file's tests. +const flakyMembers = new Map() +const failedMembersOf = (stdout: string, members: string[]) => { + const failed = new Set() + let current: string | undefined + for (const line of stdout.split("\n")) { + const heading = line.match(/^(?:.*[\\/])?test[\\/](.+\.test\.tsx?):\s*$/) + if (heading) { + const name = heading[1].replaceAll("\\", "/") + current = members.includes(name) ? name : undefined + continue + } + if (current && /^\(fail\)/.test(line.trim())) failed.add(current) + } + return [...failed] +} +// kilocode_change end const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => { while (queue.length > 0 && !stopped.value) { @@ -442,7 +709,12 @@ const workers = Array.from({ length: Math.min(concurrency, files.length) }, asyn // attempt; contention-based flakes (port races, slow FS, slow spawn) recover. // Preserve the last attempt's stdout/stderr/duration so a truly broken file // still shows a useful diagnostic. - while (!result.passed && result.attempts <= retries && !stopped.value) { + // A timed-out item already burned the full kill deadline; retrying doubles a + // pathological hang (2x600s) and can push a shard past the 45-minute job budget. + // Contention flakes fail fast and still get their retry. + while (!result.passed && !result.timedout && result.attempts <= retries && !stopped.value) { + const members = batches.get(file) // kilocode_change + if (members) flakyMembers.set(file, failedMembersOf(result.stdout, members)) // kilocode_change const retry = await run(file) retry.attempts = result.attempts + 1 result = retry @@ -511,6 +783,16 @@ if (flaky.length > 0) { // the bottom of the job page and in the workflow summary email. if (process.env.GITHUB_ACTIONS === "true") { for (const r of sorted) { + // kilocode_change start - annotate a flaky batch's failing members, not the pseudo-file + if (batches.has(r.file)) { + for (const member of flakyMembers.get(r.file) ?? []) { + console.log( + `::warning file=packages/opencode/test/${member},title=Flaky test file (in ${r.file})::passed on attempt ${r.attempts} of ${retries + 1}`, + ) + } + continue + } + // kilocode_change end const repo = `packages/opencode/test/${r.file}` console.log(`::warning file=${repo},title=Flaky test file::passed on attempt ${r.attempts} of ${retries + 1}`) } @@ -543,6 +825,40 @@ if (ci) { }) } +// kilocode_change start - refresh the measured shard weights from this run. Only a full, +// unfiltered pass measures every per-file work item, so gate on that. Batch pseudo-files +// are skipped: their members are timed collectively, so per-member entries are preserved. +if (updateDurations) { + if (patterns.length > 0 || profile || shard) { + console.log("\n--update-durations skipped: needs a full run (no patterns, profile, or shard)") + } else { + // Merge over the existing file rather than replacing it: batched members are timed + // collectively (only their pseudo-file appears in results), but their individual + // durations still feed the computed batch weights — replacing would erase them. + const fresh = Object.fromEntries( + results + // Only passing runs measure real duration: a timed-out file would record the kill + // deadline (~600s) and a failing file records contention noise, skewing LPT. + .filter((result) => result.passed && !batches.has(result.file)) + .map((result) => [result.file, Math.round(result.duration)] as const), + ) + const files = new Set(candidates) + const merged = Object.fromEntries( + Object.entries({ ...measuredDurations, ...fresh }) + .filter(([file]) => files.has(file)) + .sort(([a], [b]) => a.localeCompare(b)), + ) + await Bun.write( + path.join(root, "script", "kilocode", "test-durations.json"), + JSON.stringify(merged, null, 1) + "\n", + ) + console.log( + `\nUpdated script/kilocode/test-durations.json: ${Object.keys(fresh).length} re-measured, ${Object.keys(merged).length} total`, + ) + } +} +// kilocode_change end + await cleanBinary() process.exit(failures.length > 0 ? 1 : 0) @@ -587,7 +903,7 @@ async function merge() { const secs = (result.duration / 1000).toFixed(3) const msg = result.timedout - ? `Test file timed out after ${deadline / 1000}s` + ? `Test file timed out after ${result.deadline / 1000}s` : `Test process exited with code ${result.code}` const detail = esc((result.stderr || result.stdout || msg).slice(0, 10000)) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index a80f81cd8b4..4ba811299b0 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -383,6 +383,7 @@ const layer = Layer.effect( // kilocode_change start KiloAgent.processConfigItem(item) KiloAgent.hardenPlan(key, item, ctx.worktree, user, Permission.fromConfig(value.permission ?? {})) + KiloAgent.hardenExplore(key, item, user, Permission.fromConfig(value.permission ?? {})) } function referencePrompt(reference: KiloReference.Resolved) { diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 0fe0e687c14..18074672b92 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -77,7 +77,7 @@ export const AttachCommand = cmd({ const noReplay = args.replay === false || args.noReplay === true // kilocode_change start - const { importCloudSession, validateCloudFork } = await import("@/kilocode/cloud-session") + const { importCloudSession, validateCloudFork, reportCloudImportError } = await import("@/kilocode/cloud-session") const cloudForkError = validateCloudFork(args) if (cloudForkError) { UI.error(cloudForkError) @@ -140,14 +140,15 @@ export const AttachCommand = cmd({ directory, headers, }) - const id = await importCloudSession(sdk, args.session).catch(() => undefined) - if (!id) { - UI.error("Failed to import session from cloud") + try { + const id = await importCloudSession(sdk, args.session) + args.session = id + args.cloudFork = false + } catch (err) { + reportCloudImportError(err) process.exitCode = 1 return } - args.session = id - args.cloudFork = false } // kilocode_change end const config = await TuiConfig.get() diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 36bcabfba86..0244decdf06 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -281,7 +281,7 @@ export const RunCommand = effectCmd({ // kilocode_change start - lazy Kilo implementations (see top-of-file note) const { createKiloClient } = yield* Effect.promise(() => import("@kilocode/sdk/v2")) const { buildRunMessage } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run-message")) - const { importCloudSession, validateCloudFork } = yield* Effect.promise(() => import("@/kilocode/cloud-session")) + const { importCloudSession, validateCloudFork, reportCloudImportError } = yield* Effect.promise(() => import("@/kilocode/cloud-session")) const { KiloRunAuto } = yield* Effect.promise(() => import("@/kilocode/cli/run-auto")) const { KiloHeadless } = yield* Effect.promise(() => import("@/kilocode/permission/headless")) const { KiloRun, KiloRunDaemon } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run")) @@ -505,29 +505,29 @@ export const RunCommand = effectCmd({ async function session(sdk: KiloClient): Promise { // kilocode_change start - import cloud session before local lookup if (args.session && args["cloud-fork"]) { - const id = await importCloudSession(sdk, args.session).catch(() => undefined) - if (!id) { - UI.error("Failed to import session from cloud") + try { + const id = await importCloudSession(sdk, args.session) + const current = await sdk.session + .get({ + sessionID: id, + }) + .catch(() => undefined) + + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + model: current.data.model, + } + } catch (err) { + reportCloudImportError(err) process.exit(1) } - - const current = await sdk.session - .get({ - sessionID: id, - }) - .catch(() => undefined) - - if (!current?.data) { - UI.error("Session not found") - process.exit(1) - } - - return { - id: current.data.id, - title: current.data.title, - directory: current.data.directory, - model: current.data.model, - } } // kilocode_change end diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6258904e8c9..8ba80849c6b 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -168,6 +168,12 @@ export const TuiThreadCommand = cmd({ type: "boolean", describe: "fetch session from cloud and continue locally (use with --session)", }) + // kilocode_change start - create/reuse a git worktree before starting + .option("worktree", { + type: "string", + describe: "create (or reuse) a git worktree with this name and start kilo there", + }) + // kilocode_change end .option("prompt", { type: "string", describe: "prompt to use", @@ -259,9 +265,10 @@ export const TuiThreadCommand = cmd({ // kilocode_change start - lazy Kilo implementations so other CLI commands // don't pay their module cost at startup - const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session") + const { importCloudSession, localSessionID, validateCloudFork, reportCloudImportError } = await import("@/kilocode/cloud-session") const { KiloTuiThreadDaemon } = await import("@/kilocode/cli/cmd/tui/thread") const { preload } = await import("@/kilocode/cli/cmd/tui") + const { resolveTuiDirectory } = await import("@/kilocode/cli/cmd/tui-worktree") // kilocode_change end const unguard = win32InstallCtrlCGuard() const shutdown = { @@ -286,7 +293,14 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const next = resolveThreadDirectory(args.project) + // kilocode_change start - `--worktree ` creates/reuses a worktree; resuming + // an explicit `--session ` tries to restart in that session's worktree + const next = await resolveTuiDirectory(args, resolveThreadDirectory(args.project)).catch((error) => { + UI.error(errorMessage(error)) + process.exitCode = 1 + }) + if (!next) return + // kilocode_change end const file = await target() // kilocode_change start const preloads = preload(typeof KILO_WORKER_PATH !== "undefined", () => @@ -440,14 +454,15 @@ export const TuiThreadCommand = cmd({ headers: transport.headers, // kilocode_change directory: cwd, }) - const id = await importCloudSession(sdk, args.session).catch(() => undefined) - if (!id) { - UI.error("Failed to import session from cloud") + try { + const id = await importCloudSession(sdk, args.session) + args.session = id + args.cloudFork = false + } catch (err) { + reportCloudImportError(err) shutdownAndExit({ reason: "cloud-fork-failed", code: 1 }) return } - args.session = id - args.cloudFork = false } // kilocode_change end diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 1be61ef82a2..bda511646cc 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -10,6 +10,7 @@ import path from "path" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" +import { KilocodeConfigSources } from "../config/sources" import PROMPT_DEBUG from "../../agent/prompt/debug.txt" import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt" @@ -143,6 +144,14 @@ export const readOnlyBash: Record = { "man *-H*": "deny", } +const exploreBash: Record = { + ...readOnlyBash, + // Explore runs as a delegated agent, so it cannot answer permission prompts. + "gh *": "deny", + // `find` can mutate through `-delete` and `-exec`; use glob/list instead. + "find *": "deny", +} + function askGuard(mcp: Record = {}) { return Permission.fromConfig({ "*": "deny", @@ -223,6 +232,20 @@ export function hardenPlan( item.permission = Permission.merge(item.permission, planEditGuard(worktree), ...edit) } +export function hardenExplore( + key: string, + item: { permission: Permission.Ruleset }, + ...explicit: Permission.Ruleset[] +) { + if (key !== "explore") return + item.permission = Permission.merge( + item.permission, + Permission.fromConfig({ bash: exploreBash }), + // Hardening is a ceiling, so retain any stricter user-authored denies. + ...explicit.map(denies), + ) +} + function planGuard(worktree: string, mcp: Record = {}) { return Permission.fromConfig({ "*": "deny", @@ -445,7 +468,6 @@ export function patchAgents( grep: "allow", glob: "allow", list: "allow", - bash: "allow", skill: "allow", webfetch: "allow", websearch: "allow", @@ -462,6 +484,9 @@ export function patchAgents( }, }), user, + // Explore is always delegated, so user allows cannot make its shell writable. + Permission.fromConfig({ bash: exploreBash }), + denies(user), ), prompt: PROMPT_EXPLORE, } @@ -546,10 +571,16 @@ export const RemoveError = NamedError.create("AgentRemoveError", { /** * Remove a custom agent by deleting its markdown source file, removing it from * config-backed agent entries, and/or removing it from legacy .kilocodemodes YAML files. - * Scans all config directories for agent/mode .md files matching the name, - * then also checks the .kilocodemodes files the ModesMigrator reads. + * Scans the selected writable config scope, or every scope when none is selected. */ -export async function remove(input: { name: string; agent?: AgentInfo; dirs: string[]; directory: string }) { +export async function remove(input: { + name: string + agent?: AgentInfo + dirs: string[] + directory: string + worktree?: string + scope?: "global" | "project" +}) { if (!input.agent) throw new RemoveError({ name: input.name, message: "agent not found" }) if (input.agent.native) throw new RemoveError({ name: input.name, message: "cannot remove native agent" }) // Prevent removal of organization-managed agents @@ -561,10 +592,21 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str const { unlink, writeFile } = await import("fs/promises") let found = false + const result = await KilocodeConfigSources.list({ directory: input.directory, worktree: input.worktree }) + const sources = result.sources.filter((source) => !input.scope || source.scope === input.scope) + const roots = new Set( + sources.flatMap((source) => { + if (!source.path) return [] + if (source.kind === "config-dir") return [source.path] + if (source.kind === "global-file") return [path.dirname(source.path)] + return [] + }), + ) + const dirs = input.scope ? input.dirs.filter((dir) => roots.has(dir)) : input.dirs // 1. Delete .md files from config directories const patterns = ["{agent,agents}/**/" + input.name + ".md", "{mode,modes}/" + input.name + ".md"] - for (const dir of input.dirs) { + for (const dir of dirs) { for (const pattern of patterns) { const matches = await Glob.scan(pattern, { cwd: dir, absolute: true, dot: true }) for (const file of matches) { @@ -576,7 +618,7 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str } } - if (await removeConfigAgent(input.name, input.directory)) found = true + if (await removeConfigAgent(input.name, sources)) found = true // 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator) const { ModesMigrator } = await import("@/kilocode/modes-migrator") @@ -584,15 +626,22 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str const os = await import("os") const matter = (await import("gray-matter")).default const home = os.default.homedir() - const modesFiles = [ - path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"), - path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"), - path.join(home, ".kilocodemodes"), - path.join(input.directory, ".kilocodemodes"), + const legacy = [ + { + scope: "global" as const, + file: path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"), + }, + { + scope: "global" as const, + file: path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"), + }, + { scope: "global" as const, file: path.join(home, ".kilocodemodes") }, + { scope: "project" as const, file: path.join(input.directory, ".kilocodemodes") }, ] - for (const file of modesFiles) { - const modes = await ModesMigrator.readModesFile(file) + for (const item of legacy) { + if (input.scope && item.scope !== input.scope) continue + const modes = await ModesMigrator.readModesFile(item.file) if (!modes.length) continue const filtered = modes.filter((m: { slug: string }) => m.slug !== input.name) @@ -603,19 +652,17 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str .stringify("", { customModes: filtered }) .replace(/^---\n/, "") .replace(/\n---\n?$/, "") - await writeFile(file, yaml) + await writeFile(item.file, yaml) found = true } if (!found) throw new RemoveError({ name: input.name, message: "no agent file found on disk" }) } -async function removeConfigAgent(name: string, directory: string) { - const { KilocodeConfigOverlay } = await import("@/kilocode/config/overlay") - const files = [ - KilocodeConfigOverlay.globalTarget(), - await KilocodeConfigOverlay.projectTarget({ directory }), - ] +async function removeConfigAgent(name: string, sources: KilocodeConfigSources.Source[]) { + const files = sources + .filter((source) => source.exists && source.editable && source.path && source.kind.endsWith("-file")) + .map((source) => source.path!) let found = false for (const file of new Set(files)) { diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts new file mode 100644 index 00000000000..78ba537c957 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -0,0 +1,216 @@ +// kilocode_change - new file +// Supports `kilo --worktree ` (create/reuse a git worktree before the TUI +// starts, placed at `.kilo/worktrees/` to match Agent Manager's own +// worktrees) and resuming an explicit `--session ` in the worktree it was +// created in. +import path from "path" +import type { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { UI } from "@/cli/ui" +import { Filesystem } from "@/util/filesystem" +import { errorMessage } from "@/util/error" + +const log = Log.create({ service: "kilocode.tui-worktree" }) + +// Matches packages/kilo-vscode/src/agent-manager/WorktreeManager.ts's placement +// and its ensureGitExclude(), keeping `.kilo/worktrees/` out of `git status`. +const KILO_WORKTREE_DIR = ".kilo/worktrees" + +// Exported for tests. +export async function ensureGitExclude(root: string) { + const excludePath = path.join(root, ".git", "info", "exclude") + const current = (await Filesystem.readText(excludePath).catch(() => "")).replace(/\s+$/, "") + if (current.includes(`${KILO_WORKTREE_DIR}/`)) return + const prefix = current ? `${current}\n\n` : "" + await Filesystem.write(excludePath, `${prefix}# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`).catch((err) => + log.error("failed to update .git/info/exclude", { excludePath, err }), + ) +} + +function samePath(a: string, b: string) { + return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b +} + +// Exported for tests. +export function slugify(name: string) { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +// `InstanceStore.Interface.provide` loads (and caches) the instance context and +// scopes an effect to it; this adds the AppRuntime execution plus a matching +// `disposeDirectory` once the caller is done with `root`. +async function withInstance( + root: string, + fn: (run: (effect: Effect.Effect) => Promise) => Promise, +) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { InstanceStore } = await import("@/project/instance-store") + const run = (effect: Effect.Effect) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.provide({ directory: root }, effect))) + try { + return await fn(run) + } finally { + await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeDirectory(root))) + } +} + +type WaitResult = { ok: true } | { ok: false; message: string } + +/** Waits for `directory`'s `worktree.setup.ready`/`worktree.failed` event (full + * readiness, not just checkout); `cancel()` drops the timer/listener early. */ +function waitForWorktreeEvent( + bus: typeof import("@/bus/global").GlobalBus, + event: typeof import("@/worktree").Worktree.Event, + directory: string, + timeoutMs: number, +) { + const deferred = Promise.withResolvers() + let handler = (_e: { directory?: string; payload?: any }) => {} + const cleanup = () => { + clearTimeout(timer) + bus.off("event", handler) + } + // Not `unref()`'d: it's the only thing keeping the launcher's event loop + // alive while waiting, so a collected timer would let it exit 0 on a stall. + const timer = setTimeout(() => { + cleanup() + deferred.resolve({ ok: false, message: "Timed out waiting for the worktree to finish setting up" }) + }, timeoutMs) + handler = (e) => { + if (e.directory !== directory) return + if (e.payload?.type === event.SetupReady.type) { + cleanup() + deferred.resolve({ ok: true }) + } else if (e.payload?.type === event.Failed.type) { + cleanup() + deferred.resolve({ ok: false, message: e.payload.properties?.message ?? "Worktree setup failed" }) + } + } + bus.on("event", handler) + return { promise: deferred.promise, cancel: cleanup } +} + +// Exported for `kilo worktree create` (worktree.ts), which calls this and +// exits instead of going on to launch the TUI. +export async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_000) { + const { Worktree } = await import("@/worktree") + const { GlobalBus } = await import("@/bus/global") + const { InstanceState } = await import("@/effect/instance-state") + const { primaryWorktree } = await import("@/kilocode/primary-worktree") + const { Git } = await import("@/git") + const slug = slugify(name) + if (!slug) throw new Error(`Invalid worktree name "${name}"`) + return withInstance(root, async (run) => { + const ctx = await run(InstanceState.context) + // ctx.worktree is whichever checkout the command runs in. Creating under + // it unconditionally would nest `.kilo/worktrees/` inside another + // worktree and branch off that worktree's HEAD instead of the primary + // checkout's — refuse rather than get this wrong. + const primary = await run(primaryWorktree(ctx.worktree)) + if (primary && !samePath(primary, ctx.worktree)) + throw new Error( + `Cannot create worktree "${slug}" from inside another worktree (${ctx.worktree}). Run this from the primary checkout at ${primary} instead.`, + ) + + const directory = path.join(ctx.worktree, KILO_WORKTREE_DIR, slug) + // Trust neither signal alone: a directory can exist without a live git + // registration (orphaned), and a registration can outlive its directory + // (deleted out-of-band, or pruned). + const registered = (await run(Worktree.Service.use((svc) => svc.list()))).some((w) => + samePath(w.directory, directory), + ) + const exists = await Filesystem.exists(directory) + + if (registered && exists) { + // Assumes whatever created it already finished setting up; a worktree + // still booting when its own process was interrupted won't be detected. + UI.println(`Using existing worktree "${slug}" at ${directory}`) + return directory + } + if (exists) throw new Error(`"${directory}" already exists but is not a registered git worktree.`) + // Registered but missing: reclaim the dead registration (and its branch) + // the same way a fresh run would need to, so the name is free to reuse. + if (registered) { + await run(Worktree.Service.use((svc) => svc.remove({ directory }))).catch((error) => { + throw new Error(`Failed to reclaim stale worktree "${slug}": ${errorMessage(error)}`) + }) + } + + // `createFromInfo` below passes `branch: slug` straight to `git worktree + // add -b`, which fails outright if that branch already exists — e.g. one + // left behind by `git worktree prune` (which drops the registration but + // never deletes the branch). Clear it first so the name can be reused, but + // only with `-d` (not `-D`): it refuses unless the branch is fully merged, + // so we never silently discard unmerged work from an interrupted worktree + // or an unrelated branch that happens to share the name. + const runGit = (args: string[]) => run(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const branchRef = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${slug}`]) + if (branchRef.exitCode === 0) { + const deleted = await runGit(["branch", "-d", slug]) + if (deleted.exitCode !== 0) { + const message = deleted.stderr.toString("utf8").trim() || deleted.text().trim() + throw new Error( + `Branch "${slug}" already exists${message ? ` (${message})` : ""}. Remove it (e.g. \`git branch -D ${slug}\` if you're sure it's safe to discard) and retry.`, + ) + } + } + + await ensureGitExclude(ctx.worktree) + UI.println(`Creating worktree "${slug}"...`) + const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, directory, timeoutMs) + await run(Worktree.Service.use((svc) => svc.createFromInfo({ name: slug, branch: slug, directory }))).catch( + (error) => { + wait.cancel() + throw error + }, + ) + const result = await wait.promise + if (!result.ok) throw new Error(`Failed to create worktree "${slug}": ${result.message}`) + UI.println(`Worktree ready at ${directory}`) + return directory + }) +} + +// Reads only the session's `directory` column against a throwaway +// Database-only layer instead of the full AppRuntime (Plugin/LSP/MCP/Provider/ +// Observability/etc), since `--session ` is common and shouldn't pay for +// bootstrapping the whole app graph in the launcher process just for this. +async function resolveSessionWorktree(sessionID: string, fallback: string) { + try { + const { Effect, Schema } = await import("effect") + const { Database } = await import("@opencode-ai/core/database/database") + const { SessionTable } = await import("@opencode-ai/core/session/sql") + const { eq } = await import("drizzle-orm") + const { SessionID } = await import("@/session/schema") + const id = Schema.decodeUnknownSync(SessionID)(sessionID) + const row = await Effect.runPromise( + Database.Service.use(({ db }) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).pipe( + Effect.provide(Database.layerFromPath(Database.path())), + ), + ) + const directory = row?.directory + if (!directory || directory === fallback) return fallback + if (!(await Filesystem.exists(directory))) return fallback + UI.println(`Resuming session in its original worktree: ${directory}`) + return directory + } catch { + // Unknown session, missing directory, or lookup failure: fall back to the + // resolved cwd and let normal session validation report the real error. + return fallback + } +} + +/** + * Resolves the directory to launch the TUI in: creates/reuses `--worktree + * `, or when resuming an explicit `--session ` without `--project`, + * tries that session's original worktree. Otherwise returns `root` unchanged. + */ +export function resolveTuiDirectory(args: { worktree?: string; session?: string; project?: string }, root: string) { + if (args.worktree) return resolveWorktree(args.worktree, root) + if (args.session && !args.project) return resolveSessionWorktree(args.session, root) + return Promise.resolve(root) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts index c24f2bb0f8f..f99f05e0351 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/thread.ts @@ -3,10 +3,10 @@ import { UI } from "@/cli/ui" import type { NetworkOptions } from "@/cli/network" import { ServerAuth } from "@/server/auth" import { Flag } from "@opencode-ai/core/flag/flag" -import { errorMessage } from "@/util/error" +import { errorMessage } from "@opencode-ai/tui/util/error" import { TuiConfig } from "@/config/tui" import { validateSession } from "@/cli/tui/validate-session" -import { importCloudSession } from "@/kilocode/cloud-session" +import { importCloudSession, reportCloudImportError } from "@/kilocode/cloud-session" import { DaemonClient } from "@/kilocode/daemon/client" import { createKiloClient } from "@kilocode/sdk/v2" @@ -39,12 +39,14 @@ async function session(input: Input, daemon: DaemonClient.Connection) { directory: input.cwd, headers: daemon.headers, }) - const id = await importCloudSession(client, input.args.session).catch(() => undefined) - if (id) return { ok: true as const, id } - - UI.error("Failed to import session from cloud") - process.exitCode = 1 - return { ok: false as const } + try { + const id = await importCloudSession(client, input.args.session) + return { ok: true as const, id } + } catch (err) { + reportCloudImportError(err) + process.exitCode = 1 + return { ok: false as const } + } } export namespace KiloTuiThreadDaemon { diff --git a/packages/opencode/src/kilocode/cli/cmd/worktree.ts b/packages/opencode/src/kilocode/cli/cmd/worktree.ts new file mode 100644 index 00000000000..f9179b40d51 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/worktree.ts @@ -0,0 +1,99 @@ +// kilocode_change - new file +// `kilo worktree list`/`remove`: CLI-side counterpart to `kilo --worktree ` +// (tui-worktree.ts) and the TUI's `/worktree` alias for the workspaces dialog +// (packages/tui/src/app.tsx). All three go through the same `Worktree.Service`. +import path from "path" +import { Effect } from "effect" +import { cmd } from "@/cli/cmd/cmd" +import { CliError, effectCmd, fail } from "@/cli/effect-cmd" +import { UI } from "@/cli/ui" +import { errorMessage } from "@/util/error" +import { slugify } from "@/kilocode/cli/cmd/tui-worktree" + +const wrapErr = (message: string) => (effect: Effect.Effect) => + effect.pipe(Effect.mapError((error) => new CliError({ message: `${message}: ${errorMessage(error)}` }))) + +// Lazy: this module is imported eagerly by KiloCli.register (see setup.ts), so +// `@/worktree`'s heavier transitive graph (Project, Provider, ...) must not +// load until a handler actually runs, matching tui-worktree.ts's own imports. +// `tryPromise` (not `promise`): a failed dynamic import must flow through each +// call site's own `wrapErr`, not become an unrecoverable defect. +const importWorktree = Effect.tryPromise(() => import("@/worktree")) + +const listWorktrees = importWorktree.pipe( + Effect.flatMap(({ Worktree }) => Worktree.Service.use((svc) => svc.list())), + wrapErr("Failed to list worktrees"), +) + +export const WorktreeCommand = cmd({ + command: "worktree", + describe: "manage git worktrees", + builder: (yargs) => + yargs.command(WorktreeCreateCommand).command(WorktreeListCommand).command(WorktreeRemoveCommand).demandCommand(), + async handler() {}, +}) + +export const WorktreeCreateCommand = cmd({ + command: "create ", + describe: "create (or reuse) a git worktree by name", + builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), + async handler(args) { + // Plain cmd(), not effectCmd(): resolveWorktree loads/disposes its own + // instance context (it's shared with `kilo --worktree`'s pre-TUI-launch + // path in tui-worktree.ts), so it can't run inside effectCmd's own. + const { resolveWorktree } = await import("@/kilocode/cli/cmd/tui-worktree") + const directory = await resolveWorktree(args.name, process.cwd()).catch((error) => { + UI.error(errorMessage(error)) + process.exitCode = 1 + }) + // Prints only the resolved path on stdout (status messages already went + // to stderr via resolveWorktree's own UI.println calls), so scripts can + // do e.g. `cd "$(kilo worktree create foo)"`. + if (directory) console.log(directory) + }, +}) + +export const WorktreeListCommand = effectCmd({ + command: "list", + describe: "list git worktrees for the current project", + handler: Effect.fn("Cli.worktree.list")(function* () { + const list = yield* listWorktrees + if (!list.length) { + UI.println("No worktrees found.") + return + } + // Data rows go to stdout (like `kilo session list`'s table/JSON), not + // UI.println's stderr, so `kilo worktree list | ...` actually captures them. + for (const w of list) console.log(`${w.name}${w.branch ? ` (${w.branch})` : ""} ${w.directory}`) + }), +}) + +export const WorktreeRemoveCommand = effectCmd({ + command: "remove ", + // Worktree.Service.remove() force-deletes (`git branch -D`) the worktree's + // branch too, with no way to opt out (matches the TUI workspaces dialog's + // existing semantics) — make that explicit rather than surprise users. + describe: "remove a git worktree by name, deleting its branch too", + builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), + handler: Effect.fn("Cli.worktree.remove")(function* (args) { + const slug = slugify(args.name) + if (!slug) { + yield* fail(`Invalid worktree name "${args.name}"`) + return + } + const list = yield* listWorktrees + // Matches the reuse logic in tui-worktree.ts: list() remaps `name` to the + // project ID when a worktree's basename collides with the primary + // checkout's, so also match on the directory basename. + const found = list.find((w) => w.name.toLowerCase() === slug || path.basename(w.directory).toLowerCase() === slug) + if (!found) { + yield* fail(`No worktree named "${args.name}" found.`) + return + } + const removeMsg = `Failed to remove worktree "${args.name}"` + const { Worktree } = yield* importWorktree.pipe(wrapErr(removeMsg)) + yield* Worktree.Service.use((svc) => svc.remove({ directory: found.directory })).pipe(wrapErr(removeMsg)) + const branchNote = found.branch ? ` and branch "${found.branch}"` : "" + UI.println(`Removed worktree "${found.name}" at ${found.directory}${branchNote}`) + }), +}) diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index fd14af0f88f..d7fd3e3c071 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -11,6 +11,7 @@ import { DaemonCommand } from "@/kilocode/cli/cmd/daemon" import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup" import { RemoteCommand } from "@/cli/cmd/remote" import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config" +import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree" const log = Log.create({ service: "kilocode.cli" }) @@ -56,6 +57,7 @@ export namespace KiloCli { .command(RemoteCommand) .command(DaemonCommand) .command(ConfigCLICommand) + .command(WorktreeCommand) if (InstallationBuildKind !== "release") cli.command(DevSetupCommand).command(DevAliasCommand) // Safe self-reference: `cli` is a typed parameter and yargs `.command()` returns the same // instance, so the help command can resolve the fully-built root at handler time. This also diff --git a/packages/opencode/src/kilocode/cloud-session.ts b/packages/opencode/src/kilocode/cloud-session.ts index 28e7899472a..f148284ab98 100644 --- a/packages/opencode/src/kilocode/cloud-session.ts +++ b/packages/opencode/src/kilocode/cloud-session.ts @@ -1,3 +1,9 @@ +import { errorMessage } from "@/util/error" +import { Log } from "@opencode-ai/core/util/log" +import { UI } from "@/cli/ui" + +const log = Log.create({ service: "kilocode.cloud-session" }) + /** * Validate --cloud-fork flag combinations and return an error message if invalid. */ @@ -21,20 +27,50 @@ export function localSessionID(args: { cloudFork?: boolean; session?: string }) * Import a cloud session to local storage and return the new local session ID. * Wraps the SDK's `.kilo.cloud.session.import()` which returns `unknown` due to * the OpenAPI spec not typing the response. + * + * Throws when the import fails: with the server's error message on an HTTP + * error, or with "cloud session import returned no session id" when the + * response was malformed. */ export async function importCloudSession( client: { kilo: { cloud: { session: { - import: (params: { sessionId: string }) => Promise<{ data?: unknown }> + import: (params: { sessionId: string }) => Promise<{ data?: unknown; error?: unknown }> } } } }, sessionId: string, -): Promise { +): Promise { const result = await client.kilo.cloud.session.import({ sessionId }) + if (result.error) throw new Error(importErrorReason(result.error)) const id = (result.data as Record)?.id - return typeof id === "string" ? id : undefined + if (typeof id !== "string") throw new Error("cloud session import returned no session id") + return id +} + +/** + * Extract a human-readable reason from a failed cloud-session import. + * The gateway returns errors as `{ error: string }` (400/500), while other + * SDK error shapes carry `.message`/`.data.message`. Prefer the `error` + * field so the real server reason reaches the user instead of `[object Object]`. + */ +function importErrorReason(error: unknown): string { + const err = error as { error?: unknown } + if (typeof err.error === "string" && err.error) return err.error + return errorMessage(error) +} + +/** + * Report a failed cloud-session import: log the cause at DEBUG and surface a + * human-readable message via `UI.error`. Returns `void` on purpose — it does + * not throw, so each caller keeps its own deterministic exit semantics + * (`process.exit` / `exitCode` / `shutdownAndExit` / typed `return`). The + * caller must still perform that exit after calling this. + */ +export function reportCloudImportError(err: unknown): void { + log.debug("failed to import cloud session", { err }) + UI.error(`Failed to import session from cloud: ${errorMessage(err)}`) } diff --git a/packages/opencode/src/kilocode/memory/ports.ts b/packages/opencode/src/kilocode/memory/ports.ts index 9b38ceac89d..9f6fc42d884 100644 --- a/packages/opencode/src/kilocode/memory/ports.ts +++ b/packages/opencode/src/kilocode/memory/ports.ts @@ -153,6 +153,7 @@ function recalledMemory(turn: Turn) { // --- Model resolution + invocation (host provider/`ai` -> port ModelHandle) -------------------- function consolidationOptions(model: Provider.Model) { + if (model.api.npm === "@ai-sdk/openai-compatible") return { ...ProviderTransform.smallOptions(model), stream: false } if (model.providerID === "openai" || model.api.npm === "@ai-sdk/openai") return { store: false } return ProviderTransform.smallOptions(model) } diff --git a/packages/opencode/src/kilocode/provider/cloud-auth.ts b/packages/opencode/src/kilocode/provider/cloud-auth.ts new file mode 100644 index 00000000000..daf179acb73 --- /dev/null +++ b/packages/opencode/src/kilocode/provider/cloud-auth.ts @@ -0,0 +1,69 @@ +import type { Auth } from "@/auth" + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +export function bedrockAuth(auth: Auth.Info | undefined) { + if (auth?.type !== "api" || auth.metadata?.authType !== "accessKey") return + const secret = auth.metadata.secretAccessKey + if (!auth.key || !secret) return + return { + credentials: { + accessKeyId: auth.key, + secretAccessKey: secret, + ...(auth.metadata.sessionToken ? { sessionToken: auth.metadata.sessionToken } : {}), + }, + region: auth.metadata.region, + } +} + +export function vertexAuth(auth: Auth.Info | undefined) { + if (auth?.type !== "api") return + const credentials = (() => { + try { + const parsed = JSON.parse(auth.key) + if (!record(parsed) || parsed.type !== "service_account") return + if (typeof parsed.client_email !== "string" || !parsed.client_email.trim()) return + if (typeof parsed.private_key !== "string" || !parsed.private_key.trim()) return + return parsed + } catch { + return + } + })() + if (!credentials) return + const project = + auth.metadata?.project ?? + (typeof credentials.project_id === "string" && credentials.project_id.trim() ? credentials.project_id : undefined) + if (!project) return + return { + credentials, + project, + location: auth.metadata?.location, + } +} + +export function providerKey(providerID: string, auth: Auth.Info) { + if (auth.type !== "api") return + if (providerID === "amazon-bedrock" && auth.metadata?.authType === "accessKey") return + if ( + providerID === "google-vertex" && + (auth.metadata?.authType === "serviceAccount" || vertexAuth(auth) !== undefined) + ) + return + return auth.key +} + +const VERTEX_CREDENTIALS = "kiloVertexCredentials" + +export function vertexOptions(providerID: string, npm: string, options: Record) { + const getter = options[VERTEX_CREDENTIALS] + delete options[VERTEX_CREDENTIALS] + if (providerID !== "google-vertex" || npm.includes("@ai-sdk/openai-compatible")) return + if (typeof getter !== "function") return + options.googleAuthOptions = { credentials: getter() } +} + +export function vertexCredentials(credentials: Record) { + return { [VERTEX_CREDENTIALS]: () => credentials } +} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts b/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts index c584990646b..ec5b44b53eb 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts @@ -29,7 +29,9 @@ const CommitMessageResponse = Schema.Struct({ export class CommitMessageNoChangesError extends Schema.ErrorClass( "CommitMessageNoChangesError", -)( +)({ message: Schema.String }, { httpApiStatus: 422 }) {} + +export class CommitMessageFailedError extends Schema.ErrorClass("CommitMessageFailedError")( { message: Schema.String }, { httpApiStatus: 422 }, ) {} @@ -42,7 +44,7 @@ export const CommitMessageApi = HttpApi.make("commit-message") query: WorkspaceRoutingQuery, payload: CommitMessagePayload, success: described(CommitMessageResponse, "Generated commit message"), - error: [HttpApiError.BadRequest, CommitMessageNoChangesError], + error: [HttpApiError.BadRequest, CommitMessageNoChangesError, CommitMessageFailedError], }).annotateMerge( OpenApi.annotations({ identifier: "commitMessage.generate", diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index fa48f84e9c7..b5c486ee970 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -27,6 +27,7 @@ import { SessionID } from "@/session/schema" import { CommandFiles } from "@/kilocode/command-files" const root = "/kilocode" +const Scope = Schema.Literals(["global", "project"]) export const RemoveSkillPayload = Schema.Struct({ location: Schema.String, @@ -38,6 +39,7 @@ export const RemoveCommandPayload = Schema.Struct({ export const RemoveAgentPayload = Schema.Struct({ name: Schema.String, + scope: Schema.optional(Scope), }) export const AgentRequirementQuery = Schema.Struct({ @@ -134,7 +136,7 @@ export const KilocodeApi = HttpApi.make("kilocode") identifier: "kilocode.removeAgent", summary: "Remove a custom agent", description: - "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", + "Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state.", }), ), HttpApiEndpoint.get("notebookList", KilocodePaths.notebookList, { diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/commit-message.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/commit-message.ts index 0ba0e444e09..f0376f9e1d1 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/commit-message.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/commit-message.ts @@ -4,7 +4,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { Config } from "@/config/config" import { generateCommitMessage, NoChangesError } from "@/kilocode/commit-message" -import { CommitMessageNoChangesError, CommitMessagePayload } from "../groups/commit-message" +import { CommitMessageFailedError, CommitMessageNoChangesError, CommitMessagePayload } from "../groups/commit-message" export const commitMessageHandlers = HttpApiBuilder.group(InstanceHttpApi, "commit-message", (handlers) => Effect.gen(function* () { @@ -28,6 +28,9 @@ export const commitMessageHandlers = HttpApiBuilder.group(InstanceHttpApi, "comm if (defect instanceof NoChangesError) { return Effect.fail(new CommitMessageNoChangesError({ message: defect.message })) } + if (defect instanceof Error) { + return Effect.fail(new CommitMessageFailedError({ message: defect.message })) + } return Effect.die(defect) }), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 229dadf9602..922a9dbfa04 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -15,6 +15,7 @@ import { Notebook } from "@/kilocode/notebook/service" import { ModelUsage } from "@/kilocode/session/model-usage" import { InstanceStore } from "@/project/instance-store" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" +import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors" import { Skill } from "@/skill" import type { SessionID } from "@/session/schema" import { @@ -95,11 +96,20 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const agent = yield* agents.get(ctx.payload.name) const dirs = yield* config.directories() yield* Effect.tryPromise({ - try: () => KiloAgent.remove({ name: ctx.payload.name, agent, dirs, directory: instance.directory }), + try: () => + KiloAgent.remove({ + name: ctx.payload.name, + agent, + dirs, + directory: instance.directory, + worktree: instance.worktree, + scope: ctx.payload.scope, + }), catch: (err) => err, }).pipe( Effect.catch((err) => { - if (KiloAgent.RemoveError.isInstance(err)) return Effect.fail(new HttpApiError.BadRequest({})) + if (KiloAgent.RemoveError.isInstance(err)) + return Effect.fail(new InvalidRequestError({ message: err.data.message })) return Effect.die(err) }), ) diff --git a/packages/opencode/src/kilocode/session/ask-code-switch.txt b/packages/opencode/src/kilocode/session/ask-code-switch.txt new file mode 100644 index 00000000000..0c7cb4babaa --- /dev/null +++ b/packages/opencode/src/kilocode/session/ask-code-switch.txt @@ -0,0 +1,6 @@ + +The active agent has changed from Ask to Code. +Previous Ask-mode behavioral restrictions no longer apply. +Follow the current Code agent instructions and the permissions configured for this agent. +Ignore earlier conversation text that says you are in Ask mode or must not make changes. + diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 1e1789840ba..bd9b2b5c712 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -26,7 +26,7 @@ import { MemoryPaths } from "@kilocode/kilo-memory/effect/paths" import { MemoryMarker } from "@/kilocode/memory/marker" import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { KiloToolRegistry } from "@/kilocode/tool/registry" -import CODE_SWITCH from "@/session/prompt/code-switch.txt" +import ASK_CODE_SWITCH from "./ask-code-switch.txt" import { consumeAutoTitle, markAutoTitle } from "@/kilo-sessions/rename-adoptions" export namespace KiloSessionPrompt { @@ -458,6 +458,25 @@ export namespace KiloSessionPrompt { add(`\n${body}\n`) } + export function insertAgentSwitchReminder(input: { + agent: { name: string } + userMessage: MessageV2.WithParts + messages: MessageV2.WithParts[] + }) { + if (mode(input.agent.name) !== "code") return + const prior = input.messages.findLast((msg) => msg.info.id !== input.userMessage.info.id) + if (!prior || mode(prior.info.agent) !== "ask") return + if (input.userMessage.parts.some((part) => part.type === "text" && part.text === ASK_CODE_SWITCH)) return + return { + id: PartID.ascending(), + messageID: input.userMessage.info.id, + sessionID: input.userMessage.info.sessionID, + type: "text" as const, + text: ASK_CODE_SWITCH, + synthetic: true, + } + } + /** * Determines the close reason for a session turn. * Checks for an explicit reason first (e.g. set on error during runLoop), diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 4fdad429809..f7b35146180 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -44,9 +44,20 @@ export namespace KiloTask { * overriding the selected subagent's own allowlist with parent ask/allow rules. * * OpenCode removed parent-agent inheritance entirely in anomalyco/opencode#31696. - * Kilo intentionally differs: parent denials remain hard ceilings for Plan Mode - * and MCP restrictions, while parent ask/allow rules must not replace the - * selected subagent's policy. Preserve this distinction during upstream merges. + * Kilo intentionally differs: parent edit/notebook/MCP denials remain hard ceilings + * for Plan Mode and MCP restrictions, while parent ask/allow rules must not replace + * the selected subagent's policy. Preserve this distinction during upstream merges. + * + * Broad bash denies are deliberately NOT inherited from the calling agent. A read-only/delegating + * agent (plan, ask, orchestrator) carries a `readOnlyBash` allowlist whose deny rules + * (`*`, `git *`, shell-operator guards) exist only to shape that allowlist. Projecting + * those denies onto a writable subagent capped commands the subagent's own config + * explicitly allows (e.g. `git status`), surfacing phantom deny rules the user never + * wrote (#11523). The subagent's own bash policy governs its bash capabilities; an + * explicit session-scoped bash lockdown (sandbox / session deny) still reaches the + * child via `deriveSubagentSessionPermission`, which inherits session deny rules. Built-in + * Explore has its own enforcement-level read-only bash policy, so every caller retains that + * boundary without projecting a delegator's bash rules onto custom writable subagents. * * The caller must resolve `caller` (Agent.Info) and `session` (Session.Info) * before calling. This function is pure/synchronous. @@ -59,7 +70,8 @@ export namespace KiloTask { const rules = Permission.merge(input.caller.permission ?? [], input.session.permission ?? []) const prefixes = Object.keys(input.mcp ?? {}).map((k) => k.replace(/[^a-zA-Z0-9_-]/g, "_") + "_") const isMcp = (p: string) => prefixes.some((prefix) => p.startsWith(prefix)) - const mutation = new Set(["edit", "bash", "notebook_edit", "notebook_execute"]) + // `bash` is intentionally excluded — see the doc comment above (#11523). + const mutation = new Set(["edit", "notebook_edit", "notebook_execute"]) const inherited = rules.filter( (r: Permission.Rule) => r.action === "deny" && (mutation.has(r.permission) || isMcp(r.permission)), ) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cc6add798d6..4e5f5dac4a1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -46,6 +46,7 @@ import { wrapFirstByte, } from "@/kilocode/provider/provider" import * as ModelsRefresh from "@/kilocode/provider/models-refresh" +import { bedrockAuth, providerKey, vertexAuth, vertexCredentials, vertexOptions } from "@/kilocode/provider/cloud-auth" // kilocode_change end import { ProviderError } from "./error" @@ -325,12 +326,13 @@ function custom(dep: CustomDep): Record { "amazon-bedrock": Effect.fnUntraced(function* () { const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"] const auth = yield* dep.auth("amazon-bedrock") + const stored = bedrockAuth(auth) // kilocode_change const env = yield* dep.env() - // Region precedence: 1) config file, 2) env var, 3) default + // Region precedence: 1) config file, 2) stored credentials, 3) env var, 4) default // kilocode_change const configRegion = providerConfig?.options?.region const envRegion = env["AWS_REGION"] - const defaultRegion = configRegion ?? envRegion ?? "us-east-1" + const defaultRegion = configRegion ?? stored?.region ?? envRegion ?? "us-east-1" // kilocode_change // Profile: config file takes precedence over env var const configProfile = providerConfig?.options?.profile @@ -340,17 +342,10 @@ function custom(dep: CustomDep): Record { const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"] const configApiKey = providerConfig?.options?.apiKey - // TODO: Using process.env directly because Env.set only updates a process.env shallow copy, - // until the scope of the Env API is clarified (test only or runtime?) - const awsBearerToken = iife(() => { - const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK - if (envToken) return envToken - if (auth?.type === "api") { - process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key - return auth.key - } - return undefined - }) + // kilocode_change start - pass stored bearer tokens directly without leaking them into process.env + const awsBearerToken = + process.env.AWS_BEARER_TOKEN_BEDROCK ?? (auth?.type === "api" && !stored ? auth.key : undefined) + // kilocode_change end const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"] @@ -361,6 +356,7 @@ function custom(dep: CustomDep): Record { if ( !profile && !awsAccessKeyId && + !stored && // kilocode_change !awsBearerToken && !configApiKey && !awsWebIdentityTokenFile && @@ -372,16 +368,19 @@ function custom(dep: CustomDep): Record { const providerOptions: Record = { region: defaultRegion, + ...(stored ? { credentialProvider: async () => stored.credentials } : {}), // kilocode_change } // Only use credential chain if no bearer token exists // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens) - if (!awsBearerToken && !configApiKey) { + // kilocode_change start - stored static credentials use the credential provider above + if (!awsBearerToken && !configApiKey && !stored) { // Build credential provider options (only pass profile if specified) const credentialProviderOptions = profile ? { profile } : {} providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) } + // kilocode_change end // Add custom endpoint if specified (endpoint takes precedence over baseURL) const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL @@ -528,10 +527,12 @@ function custom(dep: CustomDep): Record { }), "google-vertex": Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() + const stored = vertexAuth(yield* dep.auth("google-vertex")) // kilocode_change // models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex; keep the wider // Google Cloud project env names as fallbacks for existing ADC setups. const project = provider.options?.project ?? + stored?.project ?? // kilocode_change env["GOOGLE_VERTEX_PROJECT"] ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? @@ -539,6 +540,7 @@ function custom(dep: CustomDep): Record { const location = String( provider.options?.location ?? + stored?.location ?? // kilocode_change env["GOOGLE_VERTEX_LOCATION"] ?? env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? @@ -560,9 +562,15 @@ function custom(dep: CustomDep): Record { options: { project, location, + ...(stored ? vertexCredentials(stored.credentials) : {}), // kilocode_change fetch: async (input: RequestInfo | URL, init?: RequestInit) => { const { GoogleAuth } = await import("google-auth-library") - const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) + // kilocode_change start - authenticate OpenAI-compatible Vertex endpoints with stored credentials + const auth = new GoogleAuth({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + ...(stored ? { credentials: stored.credentials } : {}), + }) + // kilocode_change end const client = await auth.getClient() const token = await client.getAccessToken() @@ -1607,7 +1615,7 @@ const layer = Layer.effect( if (provider.type === "api") { mergeProvider(providerID, { source: "api", - key: provider.key, + key: providerKey(providerID, provider), // kilocode_change - keep structured credentials provider-specific }) } } @@ -1753,6 +1761,7 @@ const layer = Layer.effect( try { const provider = s.providers[model.providerID] const options = { ...provider.options } + vertexOptions(model.providerID, model.api.npm, options) // kilocode_change - hydrate stored credentials if ( model.providerID === "google-vertex" && diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index a792306e0d1..386a174741e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -722,25 +722,14 @@ function anthropicUsesModernAdaptiveThinking(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } -// kilocode_change start - Claude 5+ models are adaptive thinking models like opus-4.7/4.8 -function anthropicClaude5(apiId: string) { - const id = apiId.toLowerCase() - if (id.includes("fable")) return true - const version = /(?:opus|sonnet)[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-(?:opus|sonnet)(?:[.@-]|$)/.exec(id) - return Number(version?.[1] ?? version?.[2]) >= 5 -} -// kilocode_change end - function anthropicOpus45(apiId: string) { return ["opus-4-5", "opus-4.5"].some((value) => apiId.includes(value)) } function anthropicAdaptiveEfforts(apiId: string): string[] | null { - // kilocode_change start - include Kilo Claude aliases - if (anthropicUsesModernAdaptiveThinking(apiId) || anthropicClaude5(apiId)) { + if (anthropicUsesModernAdaptiveThinking(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } - // kilocode_change end if ( ["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) => apiId.includes(v), @@ -752,7 +741,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicUsesModernAdaptiveThinking(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo aliases + return anthropicUsesModernAdaptiveThinking(apiId) } function googleThinkingLevelEfforts(apiId: string) { @@ -778,7 +767,6 @@ function wrapInSapModelParams(variants: Record>): Re function googleThinkingVariants(model: Provider.Model): Record> { const id = model.api.id.toLowerCase() - if (id.includes("gemma")) return {} // kilocode_change if (id.includes("2.5")) { return { high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, @@ -862,11 +850,9 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }]), // kilocode_change - ) + switch (model.api.npm) { case "@openrouter/ai-sdk-provider": - // kilocode_change start - preserve Kilo thinking toggles for supported OpenRouter models - if (id.includes("glm") || id.includes("kimi") || id.includes("qwen") || id.includes("minimax")) { - return { - instant: { reasoning: { enabled: false } }, - thinking: { reasoning: { enabled: true } }, - } - } - // kilocode_change end - // kilocode_change start - Mercury exposes the full OpenRouter effort set - if (model.id.includes("mercury")) { - return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }])) - } - // kilocode_change end return Object.fromEntries( (model.api.id.startsWith("openai/") || id.includes("gpt") ? openaiCompatibleReasoningEfforts(model.api.id) @@ -1102,26 +1052,12 @@ export function variants(model: Provider.Model): Record v !== "max" && v !== "xhigh") } @@ -1222,10 +1158,6 @@ export function variants(model: Provider.Model): Record !a.hidden).map((a) => a.name) diff --git a/packages/opencode/src/session/reminders.ts b/packages/opencode/src/session/reminders.ts index 0b4fcb4b362..658fe1ecde5 100644 --- a/packages/opencode/src/session/reminders.ts +++ b/packages/opencode/src/session/reminders.ts @@ -10,6 +10,7 @@ import { MessageV2 } from "./message-v2" import { Session } from "./session" import { SessionV1 } from "@opencode-ai/core/v1/session" import CODE_SWITCH from "./prompt/code-switch.txt" // kilocode_change +import ASK_CODE_SWITCH from "@/kilocode/session/ask-code-switch.txt" // kilocode_change export const apply = Effect.fn("SessionReminders.apply")(function* (input: { messages: SessionV1.WithParts[] @@ -22,8 +23,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: { const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages - // kilocode_change start - shared planning reminder path - // No-op unless the active agent is plan-like. + // kilocode_change start - shared planning / agent-switch reminder path yield* Effect.promise(() => KiloSessionPrompt.insertPlanReminders({ agent: input.agent, @@ -32,11 +32,21 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: { messages: input.messages, }), ) + const switched = KiloSessionPrompt.insertAgentSwitchReminder({ + agent: input.agent, + userMessage, + messages: input.messages, + }) + if (switched) userMessage.parts.push(yield* sessions.updatePart(switched)) // kilocode_change end if (!flags.experimentalPlanMode) { const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") - if (wasPlan && input.agent.name === "code") { + if ( + wasPlan && + input.agent.name === "code" && + !userMessage.parts.some((part) => part.type === "text" && part.text === ASK_CODE_SWITCH) + ) { // kilocode_change - renamed from "build" to "code" userMessage.parts.push({ id: PartID.ascending(), diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index ddbd83d7931..7dd68faef97 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -210,117 +210,130 @@ const MIGRATIONS: Migration[] = [ }), ] -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const git = yield* Git.Service - const locks = yield* RcMap.make({ - lookup: () => TxReentrantLock.make(), - idleTimeToLive: 0, - }) - const state = yield* Effect.cached( - Effect.gen(function* () { - const dir = path.join(Global.Path.data, "storage") - const marker = path.join(dir, "migration") - const migration = yield* fs.readFileString(marker).pipe( - Effect.map(parseMigration), - Effect.catchIf(missing, () => Effect.succeed(0)), - Effect.orElseSucceed(() => 0), - ) - for (let i = migration; i < MIGRATIONS.length; i++) { - yield* Effect.logInfo("running migration", { index: i }) - const step = MIGRATIONS[i]! - const exit = yield* Effect.exit(step(dir, fs, git)) - if (Exit.isFailure(exit)) { - yield* Effect.logError("failed to run migration", { index: i, cause: exit.cause }) - break - } - yield* fs.writeWithDirs(marker, String(i + 1)) - } - return { dir } - }), - ) - - const fail = (target: string): Effect.Effect => - Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) - - const wrap = (target: string, body: Effect.Effect) => - body.pipe(Effect.catchIf(missing, () => fail(target))) - - const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) { - yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2)) - }) - - const withResolved = ( - key: string[], - fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect, - ): Effect.Effect => - Effect.scoped( +// kilocode_change start - the storage root is injectable so tests can point Storage at a +// temporary directory instead of remapping FSUtil paths under Global.Path.data. The +// default (production) root is unchanged and resolved lazily on first use. Note for +// callers of layerFromDir: migration 1 walks `../project` relative to this directory, +// so inject `/storage` when legacy layouts must be reachable from the parent. +const make = (root?: string) => + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const locks = yield* RcMap.make({ + lookup: () => TxReentrantLock.make(), + idleTimeToLive: 0, + }) + const state = yield* Effect.cached( Effect.gen(function* () { - const target = file((yield* state).dir, key) - return yield* fn(target, yield* RcMap.get(locks, target)) + const dir = root ?? path.join(Global.Path.data, "storage") + const marker = path.join(dir, "migration") + const migration = yield* fs.readFileString(marker).pipe( + Effect.map(parseMigration), + Effect.catchIf(missing, () => Effect.succeed(0)), + Effect.orElseSucceed(() => 0), + ) + for (let i = migration; i < MIGRATIONS.length; i++) { + yield* Effect.logInfo("running migration", { index: i }) + const step = MIGRATIONS[i]! + const exit = yield* Effect.exit(step(dir, fs, git)) + if (Exit.isFailure(exit)) { + yield* Effect.logError("failed to run migration", { index: i, cause: exit.cause }) + break + } + yield* fs.writeWithDirs(marker, String(i + 1)) + } + return { dir } }), ) - const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) { - yield* withResolved(key, (target, rw) => - TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))), - ) - }) + const fail = (target: string): Effect.Effect => + Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) - const read: Interface["read"] = (key: string[]) => - Effect.gen(function* () { - const value = yield* withResolved(key, (target, rw) => - TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))), + const wrap = (target: string, body: Effect.Effect) => + body.pipe(Effect.catchIf(missing, () => fail(target))) + + const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) { + yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2)) + }) + + const withResolved = ( + key: string[], + fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const target = file((yield* state).dir, key) + return yield* fn(target, yield* RcMap.get(locks, target)) + }), ) - return value as T - }) - const update: Interface["update"] = (key: string[], fn: (draft: T) => void) => - Effect.gen(function* () { - const value = yield* withResolved(key, (target, rw) => - TxReentrantLock.withWriteLock( - rw, - Effect.gen(function* () { - const content = yield* wrap(target, fs.readJson(target)) - fn(content as T) - yield* writeJson(target, content) - return content - }), - ), + const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) { + yield* withResolved(key, (target, rw) => + TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))), ) - return value as T }) - const write: Interface["write"] = (key: string[], content: unknown) => - Effect.gen(function* () { - yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content))) - }) - - const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) { - const dir = (yield* state).dir - const cwd = path.join(dir, ...prefix) - const result = yield* fs - .glob("**/*", { - cwd, - include: "file", + const read: Interface["read"] = (key: string[]) => + Effect.gen(function* () { + const value = yield* withResolved(key, (target, rw) => + TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))), + ) + return value as T }) - .pipe(Effect.catch(() => Effect.succeed([]))) - return result - .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]) - .toSorted((a, b) => a.join("/").localeCompare(b.join("/"))) - }) - return Service.of({ - remove, - read, - update, - write, - list, - }) - }), -) + const update: Interface["update"] = (key: string[], fn: (draft: T) => void) => + Effect.gen(function* () { + const value = yield* withResolved(key, (target, rw) => + TxReentrantLock.withWriteLock( + rw, + Effect.gen(function* () { + const content = yield* wrap(target, fs.readJson(target)) + fn(content as T) + yield* writeJson(target, content) + return content + }), + ), + ) + return value as T + }) + + const write: Interface["write"] = (key: string[], content: unknown) => + Effect.gen(function* () { + yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content))) + }) + + const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) { + const dir = (yield* state).dir + const cwd = path.join(dir, ...prefix) + const result = yield* fs + .glob("**/*", { + cwd, + include: "file", + }) + .pipe(Effect.catch(() => Effect.succeed([]))) + return result + .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]) + .toSorted((a, b) => a.join("/").localeCompare(b.join("/"))) + }) + + return Service.of({ + remove, + read, + update, + write, + list, + }) + }), + ) + +const layer = make() + +// kilocode_change start +/** Storage rooted at an explicit directory — for tests and multi-instance isolation. */ +export const layerFromDir = (dir: string) => make(dir) +// kilocode_change end export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, Git.node] }) diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 208924cba5a..72ec6258302 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -1,9 +1,13 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -export const EXA_URL = process.env.EXA_API_KEY - ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` - : "https://mcp.exa.ai/mcp" +// kilocode_change start - the Exa URL is derived from a caller-provided key (resolved via +// Env.Service in the tool) instead of a module-load process.env snapshot, so BYOK keys set +// or cleared after startup are honored and the tool-env architecture ratchet stays clean. +export const EXA_BASE_URL = "https://mcp.exa.ai/mcp" +export const exaUrl = (apiKey: string | undefined) => + apiKey ? `${EXA_BASE_URL}?exaApiKey=${encodeURIComponent(apiKey)}` : EXA_BASE_URL +// kilocode_change end export const PARALLEL_URL = "https://search.parallel.ai/mcp" const McpResult = Schema.Struct({ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 716484e6e54..177c53fc0b8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -37,6 +37,7 @@ import { RepoOverviewTool } from "@/kilocode/tool/repo-overview" // kilocode_cha import { RepoCloneTool } from "./repo_clone" // kilocode_change import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change import { Auth } from "@/auth" // kilocode_change +import { Env } from "@/env" // kilocode_change - websearch resolves its config via Env.Service import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" @@ -548,6 +549,7 @@ export const node = LayerNode.suspend(() => Git.node, Bus.node, Auth.node, + Env.node, // kilocode_change - websearch resolves its config via Env.Service SessionStatus.node, AgentManager.node, Notebook.node, diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 9a812cfc626..b6629a3e094 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -8,6 +8,7 @@ import { checksum } from "@opencode-ai/core/util/encode" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { RuntimeFlags } from "@/effect/runtime-flags" import { Auth } from "@/auth" // kilocode_change - source Kilo bearer for Kilo-REST transport +import { Env } from "@/env" // kilocode_change - config via Env.Service instead of process.env reads const MAX_RESULTS = 10 // kilocode_change - cap numResults across all transports @@ -31,8 +32,13 @@ export const Parameters = Schema.Struct({ const WebSearchProviderSchema = Schema.Literals(["exa", "parallel", "kilo-exa"]) // kilocode_change - kilo-exa env override export type WebSearchProvider = Schema.Schema.Type -export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { - const override = process.env.KILO_WEBSEARCH_PROVIDER +// kilocode_change start - signature reflowed by the added override parameter (KILO_WEBSEARCH_PROVIDER resolved via Env.Service by the caller) +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: false, parallel: false }, + override?: string, +): WebSearchProvider { + // kilocode_change end if (override === "exa" || override === "parallel" || override === "kilo-exa") return override // kilocode_change - kilo-exa env override if (flags.parallel) return "parallel" if (flags.exa) return "exa" @@ -55,17 +61,20 @@ export function webSearchModelName(extra: Tool.Context["extra"]) { return (apiID ?? id)?.slice(0, 100) } -function parallelAuthHeaders() { +// kilocode_change start - API keys are resolved via Env.Service in the tool and passed down +function parallelAuthHeaders(apiKey: string | undefined) { const headers = { "User-Agent": `opencode/${InstallationVersion}` } - if (!process.env.PARALLEL_API_KEY) return headers - return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } + if (!apiKey) return headers + return { ...headers, Authorization: `Bearer ${apiKey}` } } +// kilocode_change end function callProvider( http: HttpClient.HttpClient, provider: WebSearchProvider, params: Schema.Schema.Type, ctx: Tool.Context, + keys: { exa: string | undefined; parallel: string | undefined }, // kilocode_change ) { if (provider === "parallel") { return McpWebSearch.call( @@ -80,13 +89,13 @@ function callProvider( model_name: webSearchModelName(ctx.extra), }, "25 seconds", - parallelAuthHeaders(), + parallelAuthHeaders(keys.parallel), // kilocode_change ) } return McpWebSearch.call( http, - McpWebSearch.EXA_URL, + McpWebSearch.exaUrl(keys.exa), // kilocode_change "web_search_exa", McpWebSearch.SearchArgs, { @@ -106,6 +115,7 @@ export const WebSearchTool = Tool.define( const http = yield* HttpClient.HttpClient const flags = yield* RuntimeFlags.Service const authSvc = yield* Auth.Service // kilocode_change - source Kilo bearer for Kilo-REST transport + const env = yield* Env.Service // kilocode_change - config via Env.Service instead of process.env reads return { get description() { @@ -114,10 +124,21 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const provider = selectWebSearchProvider(ctx.sessionID, { - exa: flags.enableExa, - parallel: flags.enableParallel, - }) + // kilocode_change start - config via Env.Service instead of process.env reads + const [override, exaKey, parallelKey] = yield* Effect.all([ + env.get("KILO_WEBSEARCH_PROVIDER"), + env.get("EXA_API_KEY"), + env.get("PARALLEL_API_KEY"), + ]) + const provider = selectWebSearchProvider( + ctx.sessionID, + { + exa: flags.enableExa, + parallel: flags.enableParallel, + }, + override, + ) + // kilocode_change end const title = webSearchProviderLabel(provider) // kilocode_change start - Kilo-REST Exa transport // Precedence: @@ -137,7 +158,7 @@ export const WebSearchTool = Tool.define( ? "kilo-rest" : provider === "parallel" ? "mcp-parallel" - : provider === "exa" && process.env.EXA_API_KEY + : provider === "exa" && exaKey ? "mcp-exa-byok" : provider === "exa" && kiloToken ? "kilo-rest" @@ -177,7 +198,7 @@ export const WebSearchTool = Tool.define( kiloToken, ) : Effect.die(new Error("KILO_WEBSEARCH_PROVIDER=kilo-exa requires Kilo auth; run `kilo auth login`")) - : callProvider(http, provider, params, ctx) + : callProvider(http, provider, params, ctx, { exa: exaKey, parallel: parallelKey }) // kilocode_change // kilocode_change end return { diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f8f8ab7324d..ab3c7727e7c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -277,6 +277,18 @@ const layer: Layer.Layer< }) yield* runStartScripts(info.directory, { projectID, extra }) + + // kilocode_change start - signal full readiness once setup also completes + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { + type: Event.SetupReady.type, + properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) }, + }, + }) + // kilocode_change end }) const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index ff0ecbbea17..9d9a1b4eb50 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -160,8 +160,9 @@ it.effect("subagent inherits parent session deny rules as hard runtime ceilings" }), ) -// kilocode_change start - preserve Plan mutation ceilings across Kilo task delegation -it.instance("Plan delegation preserves notebook and process mutation ceilings", () => +// kilocode_change start - preserve Plan edit/notebook ceilings across Kilo task delegation, +// but do NOT project the caller's read-only bash allowlist onto a writable subagent (#11523) +it.instance("Plan delegation preserves notebook ceilings without projecting bash denies", () => Effect.gen(function* () { const caller = yield* Agent.use.get("plan") expect(caller).toBeDefined() @@ -173,7 +174,106 @@ it.instance("Plan delegation preserves notebook and process mutation ceilings", expect(Permission.evaluate("notebook_edit", "notebook.ipynb", rules).action).toBe("deny") expect(Permission.evaluate("notebook_execute", "notebook.ipynb", rules).action).toBe("deny") - expect(Permission.evaluate("bash", "bun run server.ts", rules).action).toBe("deny") + expect(rules.filter((rule) => rule.permission === "bash")).toEqual([]) }), ) + +it.instance( + "built-in Explore enforces read-only bash for Plan and orchestrator delegation", + () => + Effect.gen(function* () { + const plan = yield* Agent.use.get("plan") + const orchestrator = yield* Agent.use.get("orchestrator") + const explore = yield* Agent.use.get("explore") + expect(plan).toBeDefined() + expect(orchestrator).toBeDefined() + expect(explore).toBeDefined() + + const inherited = (caller: Agent.Info) => + KiloTask.inherited({ + caller, + session: { permission: [] } as unknown as Parameters[0]["session"], + mcp: {}, + }) + const effective = (caller: Agent.Info) => + Permission.merge(explore!.permission, KiloTask.permissions(inherited(caller))) + const rules = effective(plan!) + + expect(Permission.evaluate("bash", "git status", rules).action).toBe("allow") + expect(Permission.evaluate("bash", "rg TODO src", rules).action).toBe("allow") + + const denied = [ + "rm -rf src", + "git push origin main", + "git -c user.name=test push origin main", + "git commit -m test", + "touch output.txt", + "mv source target", + "cp source target", + "mkdir output", + "npm install", + ] + for (const command of denied) { + expect(Permission.evaluate("bash", command, rules).action).toBe("deny") + } + + // Delegated agents cannot answer an `ask`, and raw find can mutate via -exec/-delete. + expect(Permission.evaluate("bash", "gh repo view", rules).action).toBe("deny") + expect(Permission.evaluate("bash", "find . -name '*.ts'", rules).action).toBe("deny") + expect(Permission.evaluate("bash", "touch output.txt", effective(orchestrator!)).action).toBe("deny") + }), + { + config: { + agent: { + explore: { + permission: { + bash: "allow", + }, + }, + }, + }, + }, +) + +it.instance( + "read-only caller does not cap a writable subagent's own bash allowlist", + () => + Effect.gen(function* () { + const caller = yield* Agent.use.get("plan") + const worker = yield* Agent.use.get("git_worker") + expect(caller).toBeDefined() + expect(worker).toBeDefined() + + const rules = KiloTask.inherited({ + caller: caller!, + session: { permission: [] } as unknown as Parameters[0]["session"], + mcp: {}, + }) + // The phantom deny rules the issue reports must not leak from the read-only caller. + expect(rules).not.toContainEqual({ permission: "bash", pattern: "git *", action: "deny" }) + expect(rules).not.toContainEqual({ permission: "bash", pattern: "*", action: "deny" }) + + // Mirror task.ts: the subagent runs with its own permission plus the inherited ceilings. + const effective = Permission.merge(worker!.permission, KiloTask.permissions(rules)) + expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow") + expect(Permission.evaluate("bash", "touch output.txt", effective).action).toBe("allow") + }), + { + config: { + agent: { + git_worker: { + description: "A writable subagent that runs git", + mode: "subagent", + permission: { + bash: { + "*": "ask", + "git *": "allow", + "touch *": "allow", + }, + }, + }, + }, + }, + }, +) // kilocode_change end diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index be22f5648ae..99403925aad 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -9,7 +9,7 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(89) // kilocode_change - include global.config.updated + expect(EventManifest.Latest.size).toBe(90) // kilocode_change - include global.config.updated and worktree.setup.ready expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) diff --git a/packages/opencode/test/kilocode/agent-remove.test.ts b/packages/opencode/test/kilocode/agent-remove.test.ts index 380da9a1d8a..bc6d133782a 100644 --- a/packages/opencode/test/kilocode/agent-remove.test.ts +++ b/packages/opencode/test/kilocode/agent-remove.test.ts @@ -1,9 +1,10 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" -import { mkdir } from "fs/promises" +import { mkdir, rm } from "fs/promises" import path from "path" +import { Global } from "@opencode-ai/core/global" import { parse as parseJsonc } from "jsonc-parser" -import { remove } from "../../src/kilocode/agent" +import { RemoveError, remove } from "../../src/kilocode/agent" import type { Info as AgentInfo } from "../../src/agent/agent" import { tmpdir } from "../fixture/fixture" @@ -13,7 +14,9 @@ describe("Kilo agent remove", () => { const dir = path.join(tmp.path, ".kilo") const file = path.join(dir, "kilo.jsonc") await mkdir(dir, { recursive: true }) - await Bun.write(file, `{ + await Bun.write( + file, + `{ // imported agent "default_agent": "reviewer", "agent": { @@ -24,7 +27,8 @@ describe("Kilo agent remove", () => { "model": "kilo/gpt-5" } } -}`) +}`, + ) await remove({ name: "reviewer", @@ -38,4 +42,89 @@ describe("Kilo agent remove", () => { expect(cfg.agent.reviewer).toBeUndefined() expect(cfg.agent.code.model).toBe("kilo/gpt-5") }) + + test("removes duplicate agents from every editable config source", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, ".kilo") + const files = [path.join(dir, "kilo.jsonc"), path.join(dir, "opencode.jsonc")] + await mkdir(dir, { recursive: true }) + for (const file of files) { + await Bun.write( + file, + JSON.stringify({ + default_agent: "reviewer", + agent: { + reviewer: { description: path.basename(file) }, + keep: { description: "Keep this agent" }, + }, + }), + ) + } + + await remove({ + name: "reviewer", + agent: { name: "reviewer", native: false, options: {} } as AgentInfo, + dirs: [dir], + directory: tmp.path, + }) + + for (const file of files) { + const cfg = parseJsonc(await Bun.file(file).text()) + expect(cfg.default_agent).toBeUndefined() + expect(cfg.agent.reviewer).toBeUndefined() + expect(cfg.agent.keep.description).toBe("Keep this agent") + } + }) + + test("limits removal to the selected scope", async () => { + await using tmp = await tmpdir() + const name = "scope-reviewer" + const dir = path.join(tmp.path, ".kilo") + const local = path.join(dir, "kilo.jsonc") + const global = path.join(Global.Path.config, "kilo.jsonc") + const previous = (await Bun.file(global).exists()) ? await Bun.file(global).text() : undefined + const content = JSON.stringify({ agent: { [name]: { description: "Reviews code" } } }) + await mkdir(dir, { recursive: true }) + await mkdir(Global.Path.config, { recursive: true }) + await Bun.write(local, content) + await Bun.write(global, content) + + try { + const agent = { name, native: false, options: {} } as AgentInfo + const dirs = [dir, Global.Path.config] + await remove({ name, agent, dirs, directory: tmp.path, scope: "project" }) + + expect(parseJsonc(await Bun.file(local).text()).agent[name]).toBeUndefined() + expect(parseJsonc(await Bun.file(global).text()).agent[name]).toBeDefined() + + await Bun.write(local, content) + await remove({ name, agent, dirs, directory: tmp.path, scope: "global" }) + + expect(parseJsonc(await Bun.file(local).text()).agent[name]).toBeDefined() + expect(parseJsonc(await Bun.file(global).text()).agent[name]).toBeUndefined() + } finally { + if (previous === undefined) await rm(global, { force: true }) + else await Bun.write(global, previous) + } + }) + + test("preserves organization-managed agents", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, ".kilo", "agents") + const file = path.join(dir, "reviewer.md") + await mkdir(dir, { recursive: true }) + await Bun.write(file, "---\ndescription: Reviews code\n---\n\nReview code.\n") + + const err = await remove({ + name: "reviewer", + agent: { name: "reviewer", native: false, source: "organization", options: {} } as AgentInfo, + dirs: [path.dirname(dir)], + directory: tmp.path, + }).then( + () => undefined, + (err) => err, + ) + expect(RemoveError.isInstance(err)).toBe(true) + expect(await Bun.file(file).exists()).toBe(true) + }) }) diff --git a/packages/opencode/test/kilocode/ask-switch-reminder.test.ts b/packages/opencode/test/kilocode/ask-switch-reminder.test.ts new file mode 100644 index 00000000000..846f265a143 --- /dev/null +++ b/packages/opencode/test/kilocode/ask-switch-reminder.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from "bun:test" +import ASK_CODE_SWITCH from "../../src/kilocode/session/ask-code-switch.txt" +import { SessionID, MessageID, PartID } from "../../src/session/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { MessageV2 } from "../../src/session/message-v2" +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" + +const model = { + providerID: ProviderV2.ID.make("openai"), + modelID: ModelV2.ID.make("gpt-4"), +} + +function user(input: { agent: string; text: string }) { + const id = MessageID.ascending() + const sessionID = SessionID.make("ses_test") + return { + info: { + id, + role: "user" as const, + sessionID, + time: { created: Date.now() }, + agent: input.agent, + model, + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID, + type: "text" as const, + text: input.text, + }, + ], + } satisfies MessageV2.WithParts +} + +function assistant(input: { sessionID: SessionID; parentID: MessageID; agent: string; text: string }) { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant" as const, + sessionID: input.sessionID, + time: { created: Date.now() }, + parentID: input.parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: input.agent, + agent: input.agent, + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text" as const, + text: input.text, + }, + ], + } satisfies MessageV2.WithParts +} + +function reminder(message: MessageV2.WithParts) { + return message.parts + .filter((part): part is MessageV2.TextPart => part.type === "text") + .map((part) => part.text) + .find((text) => text === ASK_CODE_SWITCH) +} + +describe("insertAgentSwitchReminder", () => { + test("injects Ask to Code reminder when the previous turn was Ask", () => { + const ask = user({ agent: "ask", text: "How does this work?" }) + const reply = assistant({ + sessionID: ask.info.sessionID, + parentID: ask.info.id, + agent: "ask", + text: "I cannot modify files in Ask mode.", + }) + const next = user({ agent: "code", text: "Please implement the change." }) + next.info.sessionID = ask.info.sessionID + for (const part of next.parts) part.sessionID = ask.info.sessionID + + const added = KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "code" }, + userMessage: next, + messages: [ask, reply, next], + }) + + expect(added?.text).toBe(ASK_CODE_SWITCH) + expect(reminder(next)).toBeUndefined() + next.parts.push(added!) + expect(reminder(next)).toBe(ASK_CODE_SWITCH) + expect(ASK_CODE_SWITCH).toContain("from Ask to Code") + expect(ASK_CODE_SWITCH).toContain("permissions configured for this agent") + expect(ASK_CODE_SWITCH).not.toContain("full toolset") + expect(ASK_CODE_SWITCH).not.toContain("You may modify files") + }) + + test("does not inject after an intermediate non-Ask agent", () => { + const ask = user({ agent: "ask", text: "How does this work?" }) + const asked = assistant({ + sessionID: ask.info.sessionID, + parentID: ask.info.id, + agent: "ask", + text: "I cannot modify files in Ask mode.", + }) + const review = user({ agent: "review", text: "Review that plan." }) + review.info.sessionID = ask.info.sessionID + const reviewed = assistant({ + sessionID: ask.info.sessionID, + parentID: review.info.id, + agent: "review", + text: "Looks good.", + }) + const next = user({ agent: "code", text: "Implement it." }) + next.info.sessionID = ask.info.sessionID + + KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "code" }, + userMessage: next, + messages: [ask, asked, review, reviewed, next], + }) + + expect(reminder(next)).toBeUndefined() + }) + + test("does not inject when staying in Ask", () => { + const ask = user({ agent: "ask", text: "Explain this." }) + const reply = assistant({ + sessionID: ask.info.sessionID, + parentID: ask.info.id, + agent: "ask", + text: "Here is the explanation.", + }) + const next = user({ agent: "ask", text: "And this file?" }) + next.info.sessionID = ask.info.sessionID + + KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "ask" }, + userMessage: next, + messages: [ask, reply, next], + }) + + expect(reminder(next)).toBeUndefined() + }) + + test("does not inject on later Code turns after the switch", () => { + const ask = user({ agent: "ask", text: "How does this work?" }) + const reply = assistant({ + sessionID: ask.info.sessionID, + parentID: ask.info.id, + agent: "ask", + text: "I cannot modify files in Ask mode.", + }) + const first = user({ agent: "code", text: "Implement it." }) + first.info.sessionID = ask.info.sessionID + const coded = assistant({ + sessionID: ask.info.sessionID, + parentID: first.info.id, + agent: "code", + text: "Done.", + }) + const later = user({ agent: "code", text: "Also add tests." }) + later.info.sessionID = ask.info.sessionID + + KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "code" }, + userMessage: later, + messages: [ask, reply, first, coded, later], + }) + + expect(reminder(later)).toBeUndefined() + }) + + test("does not inject twice on the same user message", () => { + const ask = user({ agent: "ask", text: "How does this work?" }) + const reply = assistant({ + sessionID: ask.info.sessionID, + parentID: ask.info.id, + agent: "ask", + text: "I cannot modify files in Ask mode.", + }) + const next = user({ agent: "code", text: "Please implement the change." }) + next.info.sessionID = ask.info.sessionID + + const first = KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "code" }, + userMessage: next, + messages: [ask, reply, next], + }) + next.parts.push(first!) + const second = KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "code" }, + userMessage: next, + messages: [ask, reply, next], + }) + + expect(second).toBeUndefined() + expect(next.parts.filter((part) => part.type === "text" && part.text === ASK_CODE_SWITCH)).toHaveLength(1) + }) + + test("does not inject on Plan to Code", () => { + const plan = user({ agent: "plan", text: "Make a plan." }) + const reply = assistant({ + sessionID: plan.info.sessionID, + parentID: plan.info.id, + agent: "plan", + text: "Here is the plan.", + }) + const next = user({ agent: "code", text: "Implement the plan." }) + next.info.sessionID = plan.info.sessionID + + const added = KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "code" }, + userMessage: next, + messages: [plan, reply, next], + }) + + expect(added).toBeUndefined() + expect(reminder(next)).toBeUndefined() + }) + + test("does not inject when staying in Plan", () => { + const plan = user({ agent: "plan", text: "Make a plan." }) + const reply = assistant({ + sessionID: plan.info.sessionID, + parentID: plan.info.id, + agent: "plan", + text: "Here is the plan.", + }) + const next = user({ agent: "plan", text: "Refine it." }) + next.info.sessionID = plan.info.sessionID + + const added = KiloSessionPrompt.insertAgentSwitchReminder({ + agent: { name: "plan" }, + userMessage: next, + messages: [plan, reply, next], + }) + + expect(added).toBeUndefined() + expect(reminder(next)).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts new file mode 100644 index 00000000000..627017ec0fc --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { tmpdir } from "../../../fixture/fixture" +import { ensureGitExclude, slugify } from "@/kilocode/cli/cmd/tui-worktree" +import { Filesystem } from "@/util/filesystem" + +describe("slugify", () => { + test("lowercases and dashes non-alphanumeric runs", () => { + expect(slugify("My Feature!")).toBe("my-feature") + expect(slugify("fix_bug--123")).toBe("fix-bug-123") + }) + + test("trims leading/trailing dashes", () => { + expect(slugify("--hello--")).toBe("hello") + }) + + test("returns empty for names with no alphanumeric characters", () => { + expect(slugify("!!!")).toBe("") + expect(slugify(" ")).toBe("") + }) +}) + +describe("ensureGitExclude", () => { + test("appends the exclude entry when the file exists but is empty", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(tmp.path) + expect(await Filesystem.readText(excludePath)).toContain(".kilo/worktrees/") + }) + + test("preserves existing content and adds a newline before the new entry", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "*.log") + await ensureGitExclude(tmp.path) + const content = await Filesystem.readText(excludePath) + expect(content).toContain("*.log") + expect(content).toContain(".kilo/worktrees/") + }) + + test("is idempotent when the entry already exists", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(tmp.path) + await ensureGitExclude(tmp.path) + const content = await Filesystem.readText(excludePath) + expect(content.match(/\.kilo\/worktrees\//g)?.length).toBe(1) + }) + + test("does not throw when .git/info is missing", async () => { + await using tmp = await tmpdir() + await ensureGitExclude(tmp.path) + }) +}) diff --git a/packages/opencode/test/kilocode/cloud-session.test.ts b/packages/opencode/test/kilocode/cloud-session.test.ts new file mode 100644 index 00000000000..edcce1b5f95 --- /dev/null +++ b/packages/opencode/test/kilocode/cloud-session.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, mock } from "bun:test" +import { importCloudSession, reportCloudImportError } from "../../src/kilocode/cloud-session" + +const errorMock = mock() +mock.module("@/cli/ui", () => ({ UI: { error: errorMock } })) + +type ImportResult = { data?: unknown; error?: unknown } + +const client = (imp: (params: { sessionId: string }) => Promise) => + ({ kilo: { cloud: { session: { import: imp } } } }) as Parameters[0] + +describe("importCloudSession", () => { + test("returns local id on success", async () => { + const c = client(async () => ({ data: { id: "ses_local" } })) + const id = await importCloudSession(c, "ses_cloud") + expect(id).toBe("ses_local") + }) + + test("throws when server returns HTTP error", async () => { + const c = client(async () => ({ + data: undefined, + error: { name: "GatewayError", message: "session not found", status: 404 }, + })) + await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow("session not found") + }) + + test("throws with the gateway's { error } reason (400/500 contract)", async () => { + const c = client(async () => ({ + data: undefined, + error: { error: "Invalid export data" }, + })) + await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow("Invalid export data") + }) + + test("throws when data.id is missing", async () => { + const c = client(async () => ({ data: {} })) + await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow() + }) + + test("propagates thrown fetch exceptions", async () => { + const c = client(async () => { + throw new Error("network down") + }) + await expect(importCloudSession(c, "ses_cloud")).rejects.toThrow("network down") + }) +}) + +describe("reportCloudImportError", () => { + test("surfaces the reason via UI.error and does not throw", () => { + const err = new Error("session not found") + expect(() => reportCloudImportError(err)).not.toThrow() + expect(errorMock).toHaveBeenCalledWith("Failed to import session from cloud: session not found") + }) +}) diff --git a/packages/opencode/test/kilocode/headless-session-agent.test.ts b/packages/opencode/test/kilocode/headless-session-agent.test.ts new file mode 100644 index 00000000000..f06db730924 --- /dev/null +++ b/packages/opencode/test/kilocode/headless-session-agent.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, test } from "bun:test" +import { Server } from "../../src/server/server" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +test("headless prompts preserve the agent selected when the session was created", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + agent: { + "test-engineer": { + mode: "primary", + model: "mock/custom-model", + }, + }, + provider: { + mock: { + npm: "@ai-sdk/openai-compatible", + name: "Mock", + options: { baseURL: "http://127.0.0.1:1/v1", apiKey: "test" }, + models: { + "custom-model": { + name: "Custom Model", + limit: { context: 128000, output: 8192 }, + }, + }, + }, + }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const app = Server.Default().app + const headers = { "content-type": "application/json", "x-kilo-directory": tmp.path } + const created = await app.request("/session", { + method: "POST", + headers, + body: JSON.stringify({ agent: "test-engineer" }), + }) + expect(created.status).toBe(200) + const session = (await created.json()) as { id: string; agent?: string } + expect(session.agent).toBe("test-engineer") + + const prompted = await app.request(`/session/${session.id}/message`, { + method: "POST", + headers, + body: JSON.stringify({ + noReply: true, + parts: [{ type: "text", text: "hello" }], + }), + }) + expect(prompted.status).toBe(200) + const message = (await prompted.json()) as { + info: { agent: string; model: { providerID: string; modelID: string } } + } + expect(message.info.agent).toBe("test-engineer") + expect(message.info.model).toEqual({ providerID: "mock", modelID: "custom-model" }) + + const loaded = await app.request(`/session/${session.id}`, { headers }) + expect(loaded.status).toBe(200) + const current = (await loaded.json()) as { agent?: string } + expect(current.agent).toBe("test-engineer") + }, + }) +}) diff --git a/packages/opencode/test/kilocode/memory/memory-ports.test.ts b/packages/opencode/test/kilocode/memory/memory-ports.test.ts index 8b39e8e99c0..c316dee12f4 100644 --- a/packages/opencode/test/kilocode/memory/memory-ports.test.ts +++ b/packages/opencode/test/kilocode/memory/memory-ports.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import type { LanguageModelV3 } from "@ai-sdk/provider" import { APICallError } from "ai" import { Effect } from "effect" @@ -15,11 +16,11 @@ import { MemoryModel, MemorySession } from "../../../src/kilocode/memory/ports" const pid = ProviderV2.ID.make("test") const mid = ModelV2.ID.make("fake-memory-model") -function mdl(id = mid): Provider.Model { +function mdl(id = mid, npm = "test-provider", providerID = pid): Provider.Model { return { id, - providerID: pid, - api: { id, npm: "test-provider", url: "" }, + providerID, + api: { id, npm, url: "" }, limit: { context: 100_000, output: 4_000 }, capabilities: { toolcall: true, @@ -66,12 +67,20 @@ function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: bo } function provider( - input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {}, + input: { + outputs?: (string | Error)[] + seen?: string[] + calls?: unknown[] + hang?: boolean + npm?: string + providerID?: ProviderV2.ID + } = {}, ): Provider.Interface { - const base = mdl() - const mem = mdl(ModelV2.ID.make("memory-config-model")) + const providerID = input.providerID ?? pid + const base = mdl(mid, input.npm, providerID) + const mem = mdl(ModelV2.ID.make("memory-config-model"), input.npm, providerID) const info = { - id: pid, + id: providerID, name: "Test", source: "config", env: [], @@ -79,7 +88,7 @@ function provider( models: { [base.id]: base, [mem.id]: mem }, } satisfies Provider.Info return { - list: () => Effect.succeed({ [pid]: info }), + list: () => Effect.succeed({ [providerID]: info }), getProvider: () => Effect.succeed(info), getModel: (providerID, modelID) => { const found = info.models[modelID] @@ -290,6 +299,93 @@ describe("memory ports", () => { expect(seen).toEqual(["memory-config-model", "fake-memory-model"]) }) + test("model port asks OpenAI-compatible providers for a non-streaming JSON response", async () => { + const calls: unknown[] = [] + const port = MemoryModel.port({ provider: provider({ npm: "@ai-sdk/openai-compatible", calls }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) + + const opts = calls[0] as { providerOptions?: Record } + expect(opts.providerOptions?.test?.stream).toBe(false) + }) + + test("model port still disables streaming when a compatible model is registered as openai", async () => { + const calls: unknown[] = [] + const port = MemoryModel.port({ + provider: provider({ + npm: "@ai-sdk/openai-compatible", + providerID: ProviderV2.ID.make("openai"), + calls, + }), + }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) + + const opts = calls[0] as { providerOptions?: Record } + expect(opts.providerOptions?.openai?.stream).toBe(false) + }) + + test("model port puts stream:false on the OpenAI-compatible request body", async () => { + const seen: unknown[] = [] + const server = Bun.serve({ + port: 0, + async fetch(request) { + const body = await request.json() + seen.push(body) + if ((body as { stream?: boolean }).stream !== false) { + return new Response(`data: ${JSON.stringify({ choices: [{ delta: { reasoning_content: "The" } }] })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + } + return Response.json({ + id: "mem", + object: "chat.completion", + created: 0, + model: "fake-memory-model", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: '{"topic":"t","summary":"s"}', + reasoning_content: "The user just", + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + }) + try { + const sdk = createOpenAICompatible({ + name: "test", + baseURL: `http://127.0.0.1:${server.port}/v1`, + apiKey: "unused", + }) + const port = MemoryModel.port({ + provider: { + ...provider({ npm: "@ai-sdk/openai-compatible" }), + getLanguage: () => Effect.succeed(sdk.languageModel("fake-memory-model")), + }, + }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + const result = await port.run({ + handle: resolved.handle, + system: "system", + prompt: "prompt", + timeoutMs: 30_000, + }) + + expect((seen[0] as { stream?: boolean }).stream).toBe(false) + expect(result.text).toBe('{"topic":"t","summary":"s"}') + } finally { + server.stop(true) + } + }) + test("model port retries a transient provider failure once", async () => { const calls: unknown[] = [] const err = new APICallError({ @@ -307,6 +403,8 @@ describe("memory ports", () => { await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) expect(calls).toHaveLength(2) + const opts = calls[0] as { providerOptions?: Record } + expect(opts.providerOptions?.test?.stream).toBeUndefined() }) test("model port emits a structured timeout error", async () => { diff --git a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts index 5987819421d..06f4fd952cb 100644 --- a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts +++ b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts @@ -162,6 +162,19 @@ describe("custom provider fallback reasoning efforts", () => { }) } + for (const id of ["glm-custom", "kimi-custom", "minimax-custom"]) { + test(`openai-compatible custom provider uses broad efforts for ${id}`, () => { + const npm = "@ai-sdk/openai-compatible" + const model = mockModel({ id, api: { id, url: "https://api.test.com", npm } }) + expect(ProviderTransform.variants({ ...model, variants: {} })).toEqual({}) + + const result = customProviderVariants(model, npm, ProviderTransform.variants) + expect(Object.keys(result)).toEqual(efforts) + expect(result.none?.reasoningEffort).toBe("none") + expect(result.max?.reasoningEffort).toBe("max") + }) + } + test("preserves successful heuristics", () => { const model = mockModel({ api: { id: "custom", url: "https://api.test.com", npm: "@ai-sdk/openai-compatible" } }) const generated = { low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" } } diff --git a/packages/opencode/test/kilocode/provider/cloud-auth.test.ts b/packages/opencode/test/kilocode/provider/cloud-auth.test.ts new file mode 100644 index 00000000000..0cd6340c430 --- /dev/null +++ b/packages/opencode/test/kilocode/provider/cloud-auth.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { bedrockAuth, providerKey, vertexAuth, vertexCredentials, vertexOptions } from "@/kilocode/provider/cloud-auth" + +describe("cloud provider auth", () => { + test("resolves Bedrock access key credentials", () => { + expect( + bedrockAuth({ + type: "api", + key: "AKIATEST", + metadata: { + authType: "accessKey", + secretAccessKey: "secret", + sessionToken: "token", + region: "eu-west-1", + }, + }), + ).toEqual({ + credentials: { + accessKeyId: "AKIATEST", + secretAccessKey: "secret", + sessionToken: "token", + }, + region: "eu-west-1", + }) + }) + + test("resolves Vertex service-account credentials", () => { + const credentials = { + type: "service_account", + project_id: "json-project", + client_email: "test@example.com", + private_key: "private-key", + } + expect( + vertexAuth({ + type: "api", + key: JSON.stringify(credentials), + metadata: { project: "override-project", location: "europe-west1" }, + }), + ).toEqual({ credentials, project: "override-project", location: "europe-west1" }) + }) + + test("rejects malformed Vertex credentials", () => { + expect(vertexAuth({ type: "api", key: "not json" })).toBeUndefined() + }) + + test("does not expose structured credentials as generic provider API keys", () => { + expect( + providerKey("amazon-bedrock", { + type: "api", + key: "AKIATEST", + metadata: { authType: "accessKey", secretAccessKey: "secret" }, + }), + ).toBeUndefined() + expect( + providerKey("google-vertex", { + type: "api", + key: JSON.stringify({ + type: "service_account", + project_id: "test-project", + client_email: "test@example.com", + private_key: "private-key", + }), + }), + ).toBeUndefined() + expect(providerKey("anthropic", { type: "api", key: "sk-test" })).toBe("sk-test") + }) + + test("keeps Vertex credentials out of serialized provider options", () => { + const credentials = { + type: "service_account", + client_email: "test@example.com", + private_key: "private-key", + } + const options: Record = { project: "test-project", ...vertexCredentials(credentials) } + + expect(JSON.stringify(options)).toBe('{"project":"test-project"}') + vertexOptions("google-vertex", "@ai-sdk/google-vertex", options) + expect(options).toEqual({ project: "test-project", googleAuthOptions: { credentials } }) + }) +}) diff --git a/packages/opencode/test/kilocode/provider/grok-reasoning-variants.test.ts b/packages/opencode/test/kilocode/provider/grok-reasoning-variants.test.ts deleted file mode 100644 index cd58f3c4a05..00000000000 --- a/packages/opencode/test/kilocode/provider/grok-reasoning-variants.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Provider } from "@/provider/provider" -import { ProviderTransform } from "@/provider/transform" - -function model(id: string, npm: string) { - return { - id, - providerID: "test", - api: { id, npm, url: "https://example.test/v1" }, - capabilities: { reasoning: true }, - limit: { output: 64_000 }, - } as Provider.Model -} - -describe("Grok reasoning variants", () => { - test.each([ - ["grok-3", "@ai-sdk/xai"], - ["grok-4", "@ai-sdk/xai"], - ["x-ai/grok-code-fast", "@openrouter/ai-sdk-provider"], - ])("suppresses generic variants for %s", (id, npm) => { - expect(ProviderTransform.variants(model(id, npm))).toEqual({}) - }) - - test("keeps direct grok-3-mini effort variants", () => { - expect(ProviderTransform.variants(model("grok-3-mini", "@ai-sdk/xai"))).toEqual({ - low: { reasoningEffort: "low" }, - high: { reasoningEffort: "high" }, - }) - }) - - test("keeps generic grok-4.5 effort variants", () => { - expect(ProviderTransform.variants(model("grok-4.5", "@ai-sdk/xai"))).not.toEqual({}) - }) -}) diff --git a/packages/opencode/test/kilocode/server/commit-message-no-changes.test.ts b/packages/opencode/test/kilocode/server/commit-message-no-changes.test.ts index b511d620108..eaded614701 100644 --- a/packages/opencode/test/kilocode/server/commit-message-no-changes.test.ts +++ b/packages/opencode/test/kilocode/server/commit-message-no-changes.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import path from "path" import { Server } from "../../../src/server/server" +import { CommitMessageRuntime } from "../../../src/kilocode/commit-message/generate" import { resetDatabase } from "../../fixture/db" import { disposeAllInstances, tmpdir } from "../../fixture/fixture" @@ -21,4 +23,28 @@ describe("commit-message httpapi", () => { expect(res.status).toBe(422) expect(await res.json()).toEqual({ message: "No changes found to generate a commit message for" }) }) + + test("returns 422 with the real message when generation fails", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "note.txt"), "hello") + + const model = spyOn(CommitMessageRuntime, "model").mockResolvedValue({ + providerID: "test", + id: "test-small-model", + } as never) + const generate = spyOn(CommitMessageRuntime, "generate").mockRejectedValue(new Error("provider rate limited")) + try { + const res = await Server.Default().app.request("/commit-message", { + method: "POST", + headers: { "content-type": "application/json", "x-kilo-directory": tmp.path }, + body: JSON.stringify({ path: tmp.path }), + }) + + expect(res.status).toBe(422) + expect(await res.json()).toEqual({ message: "Failed to generate commit message: provider rate limited" }) + } finally { + generate.mockRestore() + model.mockRestore() + } + }) }) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-ready.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-ready.ts new file mode 100644 index 00000000000..a4508bd32d1 --- /dev/null +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-ready.ts @@ -0,0 +1,25 @@ +import { Effect } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { isRecord } from "../../server/httpapi-exercise/assertions" +import { request } from "../../server/httpapi-exercise/backend" +import { pollWithTimeout } from "../../lib/effect" +import type { ScenarioContext } from "../../server/httpapi-exercise/types" + +export function sessionAfterDefaultAgent(ctx: ScenarioContext, title: string) { + return Effect.gen(function* () { + const session = yield* ctx.session({ title }) + // GET /api/agent and POST /permission share this directory today; a workspace/worktree + // session would boot a different AgentV2 and this wait would not cover it. + yield* pollWithTimeout( + request("GET", { path: "/api/agent", headers: ctx.headers() }).pipe( + Effect.map((result) => { + const body = result.body + if (!isRecord(body) || !Array.isArray(body.data)) return undefined + return body.data.some((item) => isRecord(item) && item.id === AgentV2.defaultID) ? (true as const) : undefined + }), + ), + "default agent never loaded before permission create", + ) + return session + }).pipe(Effect.orDie) +} diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index b861b6c34b6..53f65a371b7 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -37,6 +37,21 @@ const agent = async (dir: string) => { ) } +const duplicates = async (dir: string) => { + for (const name of ["kilo.jsonc", "opencode.jsonc"]) { + await Bun.write( + path.join(dir, ".kilo", name), + JSON.stringify({ + default_agent: "httpapi-duplicate", + agent: { + "httpapi-duplicate": { description: `Duplicate in ${name}` }, + keep: { description: "Keep this agent" }, + }, + }), + ) + } +} + const command = async (dir: string) => { await Bun.write( path.join(dir, ".kilo/command/httpapi-remove.md"), @@ -209,14 +224,17 @@ export const kiloScenarios: Scenario[] = [ .json(200, object), http.protected .get("/experimental/worktree/diff", "worktree.diff") + .inProject({ git: true }) .at((ctx) => ({ path: "/experimental/worktree/diff?base=HEAD", headers: ctx.headers() })) .json(200, array), http.protected .get("/experimental/worktree/diff/summary", "worktree.diffSummary") + .inProject({ git: true }) .at((ctx) => ({ path: "/experimental/worktree/diff/summary?base=HEAD", headers: ctx.headers() })) .json(200, array), http.protected .get("/experimental/worktree/diff/file", "worktree.diffFile") + .inProject({ git: true }) .at((ctx) => ({ path: `/experimental/worktree/diff/file?${new URLSearchParams({ base: "HEAD", file: "missing.txt" })}`, headers: ctx.headers(), @@ -620,10 +638,29 @@ export const kiloScenarios: Scenario[] = [ check(!(yield* Effect.promise(() => Bun.file(location).exists())), "removed agent should not remain on disk") }), ), + http.protected + .post("/kilocode/agent/remove", "kilocode.removeAgent.duplicates") + .inProject({ git: true, init: duplicates }) + .mutating() + .at((ctx) => ({ path: "/kilocode/agent/remove", headers: ctx.headers(), body: { name: "httpapi-duplicate" } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "duplicate agent removal should return true") + for (const name of ["kilo.jsonc", "opencode.jsonc"]) { + const cfg = yield* Effect.promise(() => Bun.file(path.join(directory(ctx), ".kilo", name)).json()) + check(!cfg.agent["httpapi-duplicate"], `removed agent should not remain in ${name}`) + check(cfg.agent.keep.description === "Keep this agent", `unrelated agent should remain in ${name}`) + check(cfg.default_agent === undefined, `removed default agent should not remain in ${name}`) + } + }), + ), http.protected .post("/kilocode/agent/remove", "kilocode.removeAgent") .at((ctx) => ({ path: "/kilocode/agent/remove", headers: ctx.headers(), body: { name: "httpapi-missing" } })) - .status(400), + .json(400, (body) => { + object(body) + check(body.message === "agent not found", "agent removal should preserve the backend error message") + }), http.protected .post("/kilocode/session-import/project", "kilocode.sessionImport.project") .mutating() diff --git a/packages/opencode/test/kilocode/session-export/e2e.test.ts b/packages/opencode/test/kilocode/session-export/e2e.test.ts index 2855b24f9b2..f2ddfbefe5c 100644 --- a/packages/opencode/test/kilocode/session-export/e2e.test.ts +++ b/packages/opencode/test/kilocode/session-export/e2e.test.ts @@ -56,7 +56,9 @@ describe("session export worker e2e", () => { async function until(check: () => boolean): Promise { const start = Date.now() - while (Date.now() - start < 1_000) { + // The capture worker competes for CPU with the rest of the suite on a loaded CI + // shard; the loop returns on success, so a generous deadline costs healthy runs nothing. + while (Date.now() - start < 15_000) { if (check()) return await new Promise((resolve) => setTimeout(resolve, 10)) } @@ -74,7 +76,10 @@ function capture(posted: unknown[], snap: ConstructorParameters[ nowMs: () => 100, syncSeq: () => seq.value++, snapshotProvider: snap, - baselineTimeoutMs: 1_000, + // Must match the generous until() budget below: on a loaded CI shard the git + // snapshot can exceed 1s, and a truncated baseline emits an empty envelope that + // makes the assertions fail no matter how long the test itself waits. + baselineTimeoutMs: 15_000, }) } diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index 82e06c47d2d..16af26481bf 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -402,7 +402,9 @@ describe("Kilo task nesting", () => { KiloSessionPrompt.guardPermissions({ agent: validator, session: child }), ) expect(child.permission).not.toContainEqual({ permission: "bash", pattern: "*", action: "ask" }) - expect(child.permission).toContainEqual({ permission: "bash", pattern: "rm -rf *", action: "deny" }) + // The calling agent's own bash policy is no longer projected onto the subagent as a + // ceiling (#11523); the subagent's own `*: deny` policy is what keeps `rm -rf` denied. + expect(child.permission).not.toContainEqual({ permission: "bash", pattern: "rm -rf *", action: "deny" }) expect({ allowed: Permission.evaluate("bash", "ansible-lint --version", effective).action, denied: Permission.evaluate("bash", "rm -rf build", effective).action, diff --git a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts deleted file mode 100644 index 998edb84f88..00000000000 --- a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ProviderTransform } from "../../src/provider/transform" - -function mockModel(overrides: Partial = {}): any { - return { - id: "test/test-model", - providerID: "test", - api: { - id: "test-model", - url: "https://api.test.com", - npm: "@ai-sdk/anthropic", - }, - name: "Test Model", - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, - limit: { context: 200_000, output: 64_000 }, - status: "active", - options: {}, - headers: {}, - release_date: "2024-01-01", - ...overrides, - } -} - -describe("ProviderTransform.variants - Claude Opus 4.7 / 4.8", () => { - test("opus-4-7 returns adaptive thinking variants including xhigh (native anthropic)", () => { - const model = mockModel({ - api: { - id: "claude-opus-4-7", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { type: "adaptive", display: "summarized" }, - effort: "xhigh", - }) - }) - - test("opus-4.7 dot-form returns adaptive thinking variants via @ai-sdk/gateway", () => { - const model = mockModel({ - id: "anthropic/claude-opus-4-7", - api: { - id: "anthropic/claude-opus-4.7", - url: "https://gateway.ai", - npm: "@ai-sdk/gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - }) - - test("opus-4-7 on bedrock returns adaptive reasoningConfig with xhigh", () => { - const model = mockModel({ - api: { - id: "anthropic.claude-opus-4-7", - url: "https://bedrock.amazonaws.com", - npm: "@ai-sdk/amazon-bedrock", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, - }) - }) - - test("opus-4-8 returns adaptive thinking variants including xhigh (native anthropic)", () => { - const model = mockModel({ - api: { - id: "claude-opus-4-8", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { type: "adaptive", display: "summarized" }, - effort: "xhigh", - }) - }) - - test("opus-4.8 dot-form returns adaptive thinking variants via @ai-sdk/gateway", () => { - const model = mockModel({ - id: "anthropic/claude-opus-4-8", - api: { - id: "anthropic/claude-opus-4.8", - url: "https://gateway.ai", - npm: "@ai-sdk/gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - }) - - test("opus-4-8 on bedrock returns adaptive reasoningConfig with xhigh", () => { - const model = mockModel({ - api: { - id: "anthropic.claude-opus-4-8", - url: "https://bedrock.amazonaws.com", - npm: "@ai-sdk/amazon-bedrock", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, - }) - }) - - test("fable returns adaptive thinking variants including xhigh (native anthropic)", () => { - const model = mockModel({ - api: { - id: "claude-fable-5", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { type: "adaptive", display: "summarized" }, - effort: "xhigh", - }) - }) - - test("fable returns adaptive thinking variants via @ai-sdk/gateway", () => { - const model = mockModel({ - id: "anthropic/claude-fable-5", - api: { - id: "anthropic/claude-fable-5", - url: "https://gateway.ai", - npm: "@ai-sdk/gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - }) - - test("fable on bedrock returns adaptive reasoningConfig with xhigh", () => { - const model = mockModel({ - api: { - id: "anthropic.claude-fable-5", - url: "https://bedrock.amazonaws.com", - npm: "@ai-sdk/amazon-bedrock", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, - }) - }) - - test("sonnet-5 returns adaptive thinking variants including xhigh (native anthropic)", () => { - const model = mockModel({ - api: { - id: "claude-sonnet-5", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { type: "adaptive", display: "summarized" }, - effort: "xhigh", - }) - }) - - test("sonnet-5 returns adaptive thinking variants via @ai-sdk/gateway", () => { - const model = mockModel({ - id: "anthropic/claude-sonnet-5", - api: { - id: "anthropic/claude-sonnet-5", - url: "https://gateway.ai", - npm: "@ai-sdk/gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - }) - - test("sonnet-5 on bedrock returns adaptive reasoningConfig with xhigh", () => { - const model = mockModel({ - api: { - id: "anthropic.claude-sonnet-5", - url: "https://bedrock.amazonaws.com", - npm: "@ai-sdk/amazon-bedrock", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, - }) - }) - - test("opus-5 returns adaptive thinking variants including xhigh (native anthropic)", () => { - const model = mockModel({ - api: { - id: "claude-opus-5", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { type: "adaptive", display: "summarized" }, - effort: "xhigh", - }) - }) - - test("opus-5 returns adaptive thinking variants via @ai-sdk/gateway", () => { - const model = mockModel({ - id: "anthropic/claude-opus-5", - api: { - id: "anthropic/claude-opus-5", - url: "https://gateway.ai", - npm: "@ai-sdk/gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - }) - - test("opus-5 on bedrock returns adaptive reasoningConfig with xhigh", () => { - const model = mockModel({ - api: { - id: "anthropic.claude-opus-5", - url: "https://bedrock.amazonaws.com", - npm: "@ai-sdk/amazon-bedrock", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, - }) - }) - - test.each([ - "claude-opus-5.3", - "claude-opus-6", - "claude-6-opus", - "claude-opus-10", - "claude-sonnet-5.3", - "claude-sonnet-6", - "claude-6-sonnet", - "claude-sonnet-10", - ])( - "%s is treated as an adaptive thinking model", - (id) => { - const model = mockModel({ - api: { - id, - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { type: "adaptive", display: "summarized" }, - effort: "xhigh", - }) - }, - ) - - test("sonnet-4.6 keeps original adaptive efforts without xhigh", () => { - const model = mockModel({ - api: { - id: "claude-sonnet-4.6", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - }) - - test("opus-4-6 keeps original adaptive efforts without xhigh", () => { - const model = mockModel({ - api: { - id: "claude-opus-4-6", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - }) -}) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index c89dd27ca6c..cc887f28614 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -96,3 +96,22 @@ const { installMemoryRuntime } = await import("../src/kilocode/memory/runtime") initProjectors() installMemoryRuntime() // kilocode_change + +// kilocode_change start - fail closed: unit tests must never open a disk database. Both DB +// path resolvers (core Database.path and the v1 client in src/storage/db.ts) honor KILO_DB +// verbatim when it is ":memory:", so asserting the resolved core path after all preload +// imports catches env mutations, import-order regressions, and channel/absolute fallbacks +// that would silently point tests at the real database under ~/.local/share/kilo. +// (Do not name the database file here: database-reset-safety.test.ts scans test sources +// for the file name next to removal calls, and this file legitimately contains fs.rm.) +if (process.env["KILO_DB"] !== ":memory:") { + throw new Error(`unit test preload: KILO_DB must be ":memory:", got "${process.env["KILO_DB"]}"`) +} +{ + const { Database } = await import("@opencode-ai/core/database/database") + const resolved = Database.path() + if (resolved !== ":memory:") { + throw new Error(`unit test preload: database path must resolve to ":memory:", got "${resolved}"`) + } +} +// kilocode_change end diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 99451cfe6e3..0a96b4782a4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3921,75 +3921,20 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) - test("minimax m3 using anthropic returns thinking toggles", () => { + test("minimax returns empty object", () => { const model = createMockModel({ - id: "minimax/minimax-m3", + id: "minimax/minimax-model", providerID: "minimax", api: { - id: "MiniMax-M3", - url: "https://api.minimax.com/anthropic/v1", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({ - none: { thinking: { type: "disabled" } }, - thinking: { thinking: { type: "adaptive" } }, - }) - }) - - test("minimax m3 using openai-compatible returns thinking toggles", () => { - const model = createMockModel({ - id: "minimax/minimax-m3", - providerID: "minimax", - api: { - id: "minimax-m3", - url: "https://api.minimax.com/v1", + id: "minimax-model", + url: "https://api.minimax.com", npm: "@ai-sdk/openai-compatible", }, }) - expect(ProviderTransform.variants(model)).toEqual({ - none: { thinking: { type: "disabled" } }, - thinking: { thinking: { type: "adaptive" } }, - }) - }) - - // kilocode_change start: minimax - test("minimax m2.7 direct anthropic provider returns instant/thinking toggle", () => { - const model = createMockModel({ - id: "minimax/MiniMax-M2.7", - providerID: "minimax", - api: { - id: "MiniMax-M2.7", - url: "https://api.minimax.io/anthropic/v1", - npm: "@ai-sdk/anthropic", - }, - }) const result = ProviderTransform.variants(model) - expect(result).toEqual({ - instant: { thinking: { type: "disabled" } }, - thinking: { thinking: { type: "adaptive" } }, - }) + expect(result).toEqual({}) }) - test("minimax via kilo gateway returns instant/thinking toggle", () => { - const model = createMockModel({ - id: "kilo/minimax/minimax-m3", - providerID: "kilo", - api: { - id: "minimax/minimax-m3", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({ - instant: { reasoning: { enabled: false } }, - thinking: { reasoning: { enabled: true } }, - }) - }) - // kilocode_change end - test("minimax m3 using anthropic returns thinking toggles", () => { const model = createMockModel({ id: "minimax/minimax-m3", @@ -4050,104 +3995,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.low).toEqual({ reasoningEffort: "low" }) - }) - - test("glm-5.2 returns native effort variants for openai-compatible providers", () => { - const model = createMockModel({ - id: "zhipuai/glm-5.2", - providerID: "zhipuai", - api: { - id: "glm-5.2", - url: "https://open.bigmodel.cn/api/paas/v4", - npm: "@ai-sdk/openai-compatible", - }, - }) - expect(ProviderTransform.variants(model)).toEqual({ - high: { reasoningEffort: "high" }, - max: { reasoningEffort: "max" }, - }) - }) - - test("recognizes GLM-5.2 provider model IDs", () => { - for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) { - const model = createMockModel({ - id: `test/${id}`, - api: { - id, - url: "https://api.test.com", - npm: "@ai-sdk/openai-compatible", - }, - }) - expect(ProviderTransform.variants(model)).toEqual({ - high: { reasoningEffort: "high" }, - max: { reasoningEffort: "max" }, - }) - } - }) - - test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => { - const model = createMockModel({ - id: "custom/my-glm", - api: { - id: "accounts/fireworks/models/glm-5p2", - url: "https://api.fireworks.ai/inference/v1", - npm: "@ai-sdk/openai-compatible", - }, - }) - expect(ProviderTransform.variants(model)).toEqual({ - high: { reasoningEffort: "high" }, - max: { reasoningEffort: "max" }, - }) - }) - - test("glm-5.2 returns openrouter effort variants for openrouter", () => { - const model = createMockModel({ - id: "openrouter/z-ai/glm-5.2", - providerID: "openrouter", - api: { - id: "z-ai/glm-5.2", - url: "https://openrouter.ai/api/v1", - npm: "@openrouter/ai-sdk-provider", - }, - }) - expect(ProviderTransform.variants(model)).toEqual({ - high: { reasoning: { effort: "high" } }, - xhigh: { reasoning: { effort: "xhigh" } }, - }) - }) - - test("glm-5.2 returns effort variants for anthropic-compatible providers", () => { - const model = createMockModel({ - id: "zai-coding-plan/glm-5.2", - providerID: "zai-coding-plan", - api: { - id: "glm-5.2", - url: "https://api.z.ai/api/anthropic", - npm: "@ai-sdk/anthropic", - }, - }) - expect(ProviderTransform.variants(model)).toEqual({ - high: { effort: "high" }, - max: { effort: "max" }, - }) - }) - - test("glm-5.2 falls back to provider defaults for other packages", () => { - const model = createMockModel({ - id: "test/glm-5.2", - api: { - id: "glm-5.2", - url: "https://api.test.com", - npm: "@ai-sdk/amazon-bedrock", - }, - }) - expect(ProviderTransform.variants(model)).toEqual({ - low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, - medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } }, - high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, - }) + expect(result).toEqual({}) }) test("glm-5.2 returns native effort variants for openai-compatible providers", () => { @@ -4383,26 +4231,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) - // kilocode_change start - test("mercury-2 returns OPENAI_EFFORTS with reasoning", () => { - const model = createMockModel({ - id: "openrouter/inception/mercury-2", - providerID: "openrouter", - api: { - id: "inception/mercury-2", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - expect(result.low).toEqual({ reasoning: { effort: "low" } }) - expect(result.high).toEqual({ reasoning: { effort: "high" } }) - }) - // kilocode_change end - - // kilocode_change start - retain the model team's explicit Grok variant policy - test("grok-4 suppresses generic provider efforts", () => { + test("grok-4 uses the provider's standard efforts", () => { const model = createMockModel({ id: "openrouter/grok-4", providerID: "openrouter", @@ -4413,9 +4242,9 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) }) - // kilocode_change end test("grok-3-mini returns low and high with reasoning", () => { const model = createMockModel({ @@ -4436,113 +4265,6 @@ describe("ProviderTransform.variants", () => { // kilocode_change start describe("@kilocode/kilo-gateway", () => { - test("claude models return empty variants (reasoning disabled)", () => { - const model = createMockModel({ - id: "kilo/anthropic/claude-sonnet-4", - providerID: "kilo", - capabilities: { reasoning: false }, - api: { - id: "anthropic/claude-sonnet-4", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual([]) - }) - - test("anthropic models in api.id return empty variants (reasoning disabled)", () => { - const model = createMockModel({ - id: "kilo/anthropic/claude-opus-4", - providerID: "kilo", - capabilities: { reasoning: false }, - api: { - id: "anthropic/claude-opus-4", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual([]) - }) - - test("gpt models return OPENAI_EFFORTS with reasoning", () => { - const model = createMockModel({ - id: "kilo/openai/gpt-5", - providerID: "kilo", - api: { - id: "openai/gpt-5", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - expect(result.low).toEqual({ reasoning: { effort: "low" } }) - }) - - test("gemini-3 models return OPENAI_EFFORTS with reasoning and encrypted content", () => { - const model = createMockModel({ - id: "kilo/google/gemini-3-pro", - providerID: "kilo", - api: { - id: "google/gemini-3-pro", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - }) - - test("non-qualifying models return empty object", () => { - const model = createMockModel({ - id: "kilo/meta/llama-4", - providerID: "kilo", - api: { - id: "meta/llama-4", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) - }) - - test("grok-3-mini returns low and high with reasoning", () => { - const model = createMockModel({ - id: "kilo/x-ai/grok-3-mini", - providerID: "kilo", - api: { - id: "x-ai/grok-3-mini", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "high"]) - expect(result.low).toEqual({ reasoning: { effort: "low" } }) - expect(result.high).toEqual({ reasoning: { effort: "high" } }) - }) - - test("codex models return OPENAI_EFFORTS with object-based reasoning format", () => { - const model = createMockModel({ - id: "kilo/openai/gpt-5.2-codex", - providerID: "kilo", - api: { - id: "openai/gpt-5.2-codex", - url: "https://gateway.kilo.ai", - npm: "@kilocode/kilo-gateway", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - expect(result.low).toEqual({ reasoning: { effort: "low" } }) - expect(result.high).toEqual({ reasoning: { effort: "high" } }) - expect(result.xhigh).toEqual({ reasoning: { effort: "xhigh" } }) - }) - - // kilocode_change start test("mercury-2 uses server-provided variants from kilo gateway", () => { const serverVariants = { low: { reasoningEffort: "low" }, @@ -4563,7 +4285,6 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual(serverVariants) expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) - // kilocode_change end }) // kilocode_change end @@ -4962,30 +4683,6 @@ describe("ProviderTransform.variants", () => { }) }) - // kilocode_change start - describe("@ai-sdk/azure", () => { - test("gpt-5.4 includes xhigh", () => { - const model = createMockModel({ - id: "gpt-5.4", - release_date: "2026-03-05", - providerID: "azure", - api: { - id: "gpt-5.4", - url: "https://resource.openai.azure.com/openai", - npm: "@ai-sdk/azure", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) - expect(result.xhigh).toEqual({ - reasoningEffort: "xhigh", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }) - }) - }) - // kilocode_change end - describe("@ai-sdk/cerebras", () => { test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { const model = createMockModel({ @@ -5089,24 +4786,6 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) - // kilocode_change start - test("mercury-2 returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { - const model = createMockModel({ - id: "inception/mercury-2", - providerID: "inception", - api: { - id: "mercury-2", - url: "https://api.inceptionlabs.ai", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.low).toEqual({ reasoningEffort: "low" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - }) - // kilocode_change end - test("north-mini-code-1-0 returns only none and high", () => { const model = createMockModel({ id: "cohere/north-mini-code-1-0", @@ -5174,8 +4853,6 @@ describe("ProviderTransform.variants", () => { }) for (const testCase of [ - { id: "o3-deep-research", efforts: ["medium"] }, // kilocode_change - preserve helper exclusions on Azure - { id: "gpt-5-pro", efforts: ["high"] }, // kilocode_change - preserve helper exclusions on Azure { id: "gpt-5-1", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5-4", efforts: ["none", "low", "medium", "high", "xhigh"] }, { id: "gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, @@ -5980,43 +5657,6 @@ describe("ProviderTransform.variants", () => { }) }) - describe("@ai-sdk/groq", () => { - test("returns none and WIDELY_SUPPORTED_EFFORTS with thinkingLevel", () => { - const model = createMockModel({ - id: "groq/llama-4", - providerID: "groq", - api: { - id: "llama-4-sc", - url: "https://api.groq.com", - npm: "@ai-sdk/groq", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"]) - expect(result.none).toEqual({ - reasoningEffort: "none", - }) - expect(result.low).toEqual({ - reasoningEffort: "low", - }) - }) - }) - - describe("@ai-sdk/perplexity", () => { - test("returns empty object", () => { - const model = createMockModel({ - id: "perplexity/sonar-plus", - providerID: "perplexity", - api: { - id: "sonar-plus", - url: "https://api.perplexity.ai", - npm: "@ai-sdk/perplexity", - }, - }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) - }) - }) // kilocode_change end describe("ai-gateway-provider (cloudflare-ai-gateway)", () => { diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index 181fbd261f0..d484b9727a3 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -3,6 +3,7 @@ import { HttpRouter } from "effect/unstable/http" import { parse } from "./assertions" import { runtime, type Runtime } from "./runtime" import type { ActiveScenario, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" +import type { Method, RequestSpec } from "./types" // kilocode_change type CallOptions = { auth?: { @@ -17,6 +18,23 @@ export function call(scenario: ActiveScenario, ctx: SeededContext, opti ) } +// kilocode_change start +export function request(method: Method, spec: RequestSpec) { + return Effect.promise(async () => + capture( + await app(await runtime(), {}).request( + new Request(new URL(spec.path, "http://localhost"), { + method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }), + ), + "full", + ), + ) +} +// kilocode_change end + export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" | "valid" = "missing") { return Effect.promise(async () => { const controller = new AbortController() diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts index 959b1a21411..8164f001166 100644 --- a/packages/opencode/test/server/httpapi-exercise/dsl.ts +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -22,7 +22,9 @@ class ScenarioBuilder { method, path, name, - project: { git: true }, + // kilocode_change start - default to an in-memory project dir; opt into git init only for routes + // that exercise VCS primitives or HEAD-based diffs to avoid ~70 redundant `git init` calls per run. + project: { git: false }, // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it. seed: () => Effect.succeed(undefined as S), request: (ctx) => ({ path, headers: ctx.headers() }), @@ -39,12 +41,12 @@ class ScenarioBuilder { return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) } - inProject(project: ProjectOptions = { git: true }) { + inProject(project: ProjectOptions = { git: false }) { return this.clone({ project }) } withLlm() { - return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + return this.clone({ project: { ...(this.state.project ?? { git: false }), llm: true } }) } at(request: BuilderState["request"]) { diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 2b022aeb9aa..38197c22179 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -31,12 +31,13 @@ import { exerciseGlobalRoot, } from "./environment" import { color, printHeader, printResults } from "./report" -import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios } from "./routing" +import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios, shardScenarios } from "./routing" // kilocode_change import { runScenario } from "./runner" import { disposeApps } from "./backend" -import { runtime } from "./runtime" -import { type Scenario } from "./types" +import { publicApi, runtime } from "./runtime" // kilocode_change +import { type Scenario, type Result } from "./types" import { kiloScenarios } from "../../kilocode/server/httpapi-exercise-scenarios" // kilocode_change +import { sessionAfterDefaultAgent } from "../../kilocode/server/httpapi-exercise-ready" // kilocode_change function cursor(input: Record) { return Buffer.from(JSON.stringify(input)).toString("base64url") @@ -116,18 +117,21 @@ const scenarios: Scenario[] = [ }, "status", ), - http.protected.get("/path", "path.get").json(200, (body, ctx) => { + // kilocode_change start - opt into git init; the worktree assertion requires a git-backed project + http.protected.get("/path", "path.get").inProject({ git: true }).json(200, (body, ctx) => { object(body) check(body.directory === ctx.directory, "directory should resolve from x-kilo-directory") check(body.worktree === ctx.directory, "worktree should resolve from x-kilo-directory") }), - http.protected.get("/vcs", "vcs.get").json(), - http.protected.get("/vcs/status", "vcs.status").json(200, array), + // kilocode_change end + http.protected.get("/vcs", "vcs.get").inProject({ git: true }).json(), + http.protected.get("/vcs/status", "vcs.status").inProject({ git: true }).json(200, array), http.protected .get("/vcs/diff", "vcs.diff") + .inProject({ git: true }) .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) .json(200, array), - http.protected.get("/vcs/diff/raw", "vcs.diff.raw").status( + http.protected.get("/vcs/diff/raw", "vcs.diff.raw").inProject({ git: true }).status( 200, (_ctx, result) => Effect.sync(() => { @@ -164,17 +168,27 @@ const scenarios: Scenario[] = [ .status(400), http.protected.get("/config/providers", "config.providers").json(), http.protected.get("/project", "project.list").json(200, array, "status"), - http.protected.get("/project/current", "project.current").json( - 200, - (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") - }, - "status", - ), + // kilocode_change start - opt into git init; the worktree assertion requires a git-backed project + http.protected + .get("/project/current", "project.current") + .inProject({ git: true }) + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), + // kilocode_change end http.protected .patch("/project/{projectID}", "project.update") .mutating() + // kilocode_change start - isolated git project: a non-git directory resolves to the + // shared global project (worktree "/"), and this scenario PATCHes the row — without + // isolation the mutation would leak into every later non-git scenario in the same pass. + .inProject({ git: true }) + // kilocode_change end .seeded((ctx) => ctx.project()) .at((ctx) => ({ path: route("/project/{projectID}", { projectID: ctx.state.id }), @@ -577,10 +591,11 @@ const scenarios: Scenario[] = [ })) .json(200, array, "status"), http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array), - http.protected.get("/experimental/worktree", "worktree.list").json(200, array), + http.protected.get("/experimental/worktree", "worktree.list").inProject({ git: true }).json(200, array), // kilocode_change http.protected .post("/experimental/worktree", "worktree.create") .mutating() + .inProject({ git: true }) .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) .jsonEffect( 200, @@ -599,6 +614,7 @@ const scenarios: Scenario[] = [ http.protected .delete("/experimental/worktree", "worktree.remove") .mutating() + .inProject({ git: true }) .seeded((ctx) => ctx.worktree({ name: "api-remove" })) .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) .json(200, (body) => { @@ -607,6 +623,7 @@ const scenarios: Scenario[] = [ http.protected .post("/experimental/worktree/reset", "worktree.reset") .mutating() + .inProject({ git: true }) .seeded((ctx) => ctx.worktree({ name: "api-reset" })) .at((ctx) => ({ path: "/experimental/worktree/reset", @@ -861,17 +878,20 @@ const scenarios: Scenario[] = [ }), http.protected .post("/api/session/{sessionID}/permission", "v2.session.permission.create") - .seeded((ctx) => ctx.session({ title: "Permission create owner" })) + .seeded((ctx) => sessionAfterDefaultAgent(ctx, "Permission create owner")) // kilocode_change .at((ctx) => ({ path: route("/api/session/{sessionID}/permission", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { action: "read", resources: [".env"] }, })) + // kilocode_change start - opt into git init; the permission resolver consults VCS state for unknown resources + .inProject({ git: true }) + // kilocode_change end .json(200, (body) => { object(body) object(body.data) check(typeof body.data.id === "string", "permission create should return an ID") - check(body.data.effect === "ask", "permission create should create a pending request") + check(body.data.effect === "ask", `permission create should create a pending request, got ${JSON.stringify(body.data.effect)}`) // kilocode_change }), http.protected .get("/api/session/{sessionID}/permission", "v2.session.permission.list") @@ -1314,6 +1334,7 @@ const scenarios: Scenario[] = [ }), http.protected .get("/session/{sessionID}/diff", "session.diff") + .inProject({ git: true }) .seeded((ctx) => ctx.session({ title: "Diff session" })) .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) .json(200, array), @@ -1784,7 +1805,35 @@ const llmScenarios = new Set([ ]) const main = Effect.gen(function* () { - // kilocode_change start - dispose final non-mutating instances so shared test scopes can close + const options = parseOptions(Bun.argv.slice(2)) + for (const scenario of scenarios) { + if (scenario.kind === "active" && llmScenarios.has(scenario.name) && !scenario.project?.llm) { + return yield* Effect.fail(new Error(`${scenario.name} must use TestLLMServer via .withLlm()`)) + } + } + // kilocode_change start - coverage only needs the OpenAPI group; bypass runtime + LLM + DB allocation + if (options.mode === "coverage") { + const selected = selectedScenarios(options, scenarios) + const effectRoutes = routeKeys(OpenApi.fromApi(yield* Effect.promise(() => publicApi()))) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + printHeader(options, effectRoutes, selected, missing, extra, { + database: exerciseDatabasePath, + global: exerciseGlobalRoot, + }) + const results = selected.map(coverageResult) + printResults(results, missing, extra) + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) + yield* cleanupExercisePaths + return undefined + } + // kilocode_change end + // kilocode_change start - dispose final non-mutating instances so shared test scopes can close; skip when coverage returned early so runtime() is never imported in static mode yield* Effect.addFinalizer(() => Effect.gen(function* () { const modules = yield* Effect.promise(() => runtime()) @@ -1794,36 +1843,53 @@ const main = Effect.gen(function* () { }), ) // kilocode_change end - const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) const selected = selectedScenarios(options, scenarios) + const sharded = shardScenarios(selected, options.shard) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) - for (const scenario of scenarios) { - if (scenario.kind === "active" && llmScenarios.has(scenario.name) && !scenario.project?.llm) { - return yield* Effect.fail(new Error(`${scenario.name} must use TestLLMServer via .withLlm()`)) - } - } - - printHeader(options, effectRoutes, selected, missing, extra, { + printHeader(options, effectRoutes, sharded, missing, extra, { database: exerciseDatabasePath, global: exerciseGlobalRoot, }) - const results = - options.mode === "coverage" - ? selected.map(coverageResult) - : yield* Effect.forEach( - selected, - (scenario) => - Effect.gen(function* () { - if (options.progress) console.log(`${color.dim}RUN ${routeKey(scenario)} ${scenario.name}${color.reset}`) - return yield* runScenario(options)(scenario) - }), - { concurrency: 1 }, - ) + const results: Result[] = yield* Effect.forEach( + sharded, + (scenario) => + Effect.gen(function* () { + if (options.progress) console.log(`${color.dim}RUN ${routeKey(scenario)} ${scenario.name}${color.reset}`) + // kilocode_change start - retry a failed scenario before failing the pass, mirroring + // the unit runner's pass-after-retry policy: contention flakes recover while real + // regressions still fail every attempt. Retries stay visible in output. + const result = yield* runScenario(options)(scenario) + if (result.status !== "fail") return result + // preserveDatabase scenarios skip resetState between attempts; retrying them + // would run against state the failed attempt already mutated and can mask a + // real regression as FLAKY. Everything else resets, so retrying is sound. + if (scenario.kind !== "active" || !scenario.reset) return result + // Back off before retrying: the observed failure mode is a readiness race (agent + // projections still warming when the request lands). Sharded child processes run + // fewer scenarios each, so early scenarios land in a colder process where one + // second is not always enough. + let last: Result = result + for (const backoff of ["1 second", "3 seconds"] as const) { + console.log( + `${color.yellow}RETRY${color.reset} ${routeKey(scenario)} ${scenario.name} failed, retrying in ${backoff}`, + ) + yield* Effect.sleep(backoff) + last = yield* runScenario(options)(scenario) + if (last.status !== "fail") { + console.log(`${color.yellow}FLAKY${color.reset} ${routeKey(scenario)} ${scenario.name} passed on retry`) + break + } + } + return last + // kilocode_change end + }), + { concurrency: 1 }, + ) printResults(results, missing, extra) if (results.some((result) => result.status === "fail")) @@ -1835,11 +1901,10 @@ const main = Effect.gen(function* () { return undefined }) -// kilocode_change start - route-only coverage must not acquire a listening fake LLM server +// kilocode_change start - route-only coverage must not acquire a listening fake LLM server; effect/auth runs alone +const initialOptions = parseOptions(Bun.argv.slice(2)) const llm = - parseOptions(Bun.argv.slice(2)).mode === "coverage" - ? Layer.mock(TestLLMServer)({ url: "http://coverage.invalid" }) - : TestLLMServer.layer + initialOptions.mode === "coverage" ? Layer.mock(TestLLMServer)({ url: "http://coverage.invalid" }) : TestLLMServer.layer Effect.runPromise(main.pipe(Effect.provide(llm), Effect.scoped)).then( () => process.exit(0), diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index 9e432af2e3b..f6b69cb36e8 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -52,9 +52,31 @@ export function parseOptions(args: string[]): Options { scenarioTimeout: parseScenarioTimeout(option(args, "--scenario-timeout") ?? "30 seconds"), progress: args.includes("--progress"), trace: args.includes("--trace"), + // kilocode_change start - each shard owns an isolated DB already; --shard / lets the runner + // distribute the work across processes/cores. The PID-keyed DB means shards are safe to run in parallel. + shard: parseShard(option(args, "--shard")), + // kilocode_change end } } +// kilocode_change start - allow external (CI) callers to fan the exerciser out across N processes by index +export function shardScenarios(items: T[], shard: { index: number; total: number }): T[] { + if (shard.total <= 1) return items + return items.filter((_item, i) => i % shard.total === shard.index) +} + +function parseShard(input: string | undefined): { index: number; total: number } { + if (!input) return { index: 0, total: 1 } + const match = input.match(/^(\d+)\/(\d+)$/) + if (!match) throw new Error(`invalid --shard ${input}, expected /`) + const index = Number(match[1]) + const total = Number(match[2]) + if (total < 1) throw new Error(`--shard total must be >= 1, got ${total}`) + if (index < 0 || index >= total) throw new Error(`--shard index must be in 0..${total - 1}, got ${index}`) + return { index, total } +} +// kilocode_change end + export function matches(options: Options, scenario: Scenario) { if (!options.include) return true return ( diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 5f790e5ccfd..e7adb10a6bb 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -38,7 +38,18 @@ function runActive(options: Options, scenario: ActiveScenario) { const result = yield* call(scenario, ctx) yield* trace(options, scenario, `response ${result.status}`) yield* trace(options, scenario, "expect start") - yield* scenario.expect(ctx, ctx.state, result) + // kilocode_change start - append the actual response to assertion failures so CI logs + // show what the route returned, not just which expectation broke + yield* scenario.expect(ctx, ctx.state, result).pipe( + Effect.catchCause((cause) => + Effect.die( + new Error( + `${Cause.pretty(cause)}\n response ${result.status}: ${result.text.slice(0, 1000)}`, + ), + ), + ), + ) + // kilocode_change end yield* trace(options, scenario, "expect done") }), ) @@ -124,12 +135,15 @@ function withContext( if (!context.llm) throw new Error("scenario needs fake LLM") return context.llm } + // kilocode_change start - headers closure extracted so scenarios can build their own requests + const headers = (extra?: Record) => ({ + ...(context.dir?.path ? { "x-kilo-directory": context.dir.path } : {}), + ...extra, + }) + // kilocode_change end const base: ScenarioContext = { directory: context.dir?.path, - headers: (extra) => ({ - ...(context.dir?.path ? { "x-kilo-directory": context.dir.path } : {}), - ...extra, - }), + headers, // kilocode_change file: (name, content) => Effect.promise(() => { return Bun.write(`${directory()}/${name}`, content) diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index 86ae923006b..317588b6de8 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -14,6 +14,14 @@ export type Runtime = { tmpdir: (typeof import("../../fixture/fixture"))["tmpdir"] } +// kilocode_change start - coverage only needs the OpenAPI group; bypass the full runtime import to keep +// static route-coverage checks sub-second on CI +export async function publicApi() { + const publicApiModule = await import("../../../src/server/routes/instance/httpapi/public") + return publicApiModule.PublicApi +} +// kilocode_change end + let runtimePromise: Promise | undefined export function runtime() { diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 4f26227cf87..01dcc6af3fd 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -35,6 +35,7 @@ export type Options = { scenarioTimeout: Duration.Duration progress: boolean trace: boolean + shard: { index: number; total: number } // kilocode_change - opt-in sharding so CI runners can fan out across processes } export type RequestSpec = { diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 13f5bce2c9b..cc61a0c54fe 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -156,7 +156,10 @@ describe("pty HttpApi bridge", () => { // Exited sessions are retained by core for the canonical surface, but the legacy // routes preserve pre-retention behavior: exited sessions are invisible here. - const deadline = Date.now() + 5_000 + // kilocode_change start - exit propagation can exceed 5s on a loaded CI shard; the loop + // breaks as soon as the session disappears, so a generous deadline costs nothing when healthy. + const deadline = Date.now() + 30_000 + // kilocode_change end while (Date.now() < deadline) { const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) if (found.status === 404) break diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index a3f3aef4464..4d8200e1c02 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -24,41 +24,18 @@ const scope = Effect.fnUntraced(function* () { return { root, svc } }) -// remap(root) rewrites any path under Global.Path.data to live under `root` instead. -// Used by remappedFs to build an FSUtil that Storage thinks is the real global -// data dir but actually targets a tmp dir — letting migration tests stage legacy layouts. -// NOTE: only the 6 methods below are intercepted. If Storage starts using a different -// FSUtil method that touches Global.Path.data, add it here. -function remap(root: string, file: string) { - if (file === Global.Path.data) return root - if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file)) - return file -} - -function remappedFs(root: string) { - return Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - return FSUtil.Service.of({ - ...fs, - isDir: (file) => fs.isDir(remap(root, file)), - readJson: (file) => fs.readJson(remap(root, file)), - writeWithDirs: (file, content, mode) => fs.writeWithDirs(remap(root, file), content, mode), - readFileString: (file) => fs.readFileString(remap(root, file)), - remove: (file) => fs.remove(remap(root, file)), - glob: (pattern, options) => - fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options), - }) - }), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) -} - -// Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache -// returns the outer testEffect's Storage (which uses the real FSUtil), not a new -// one built on top of remappedFs. -const remappedStorage = (root: string) => - Layer.fresh(LayerNode.compile(Storage.node, [[FSUtil.node, remappedFs(root)]])) +// kilocode_change start - Storage rooted at `/storage` via the injectable layer — migration +// tests stage legacy layouts (e.g. `/project`) as siblings, matching migration 1's +// `../project` walk. Layer.fresh forces a new Storage instance — without it, Effect's in-test +// layer cache returns the outer testEffect's Storage (rooted at the real data dir), not a new +// one rooted at the tmp dir. +const injectedStorage = (root: string) => + Layer.fresh( + Storage.layerFromDir(path.join(root, "storage")).pipe( + Layer.provide(LayerNode.compile(LayerNode.group([FSUtil.node, Git.node]))), + ), + ) +// kilocode_change end describe("Storage", () => { it.live("round-trips JSON content", () => @@ -228,7 +205,7 @@ describe("Storage", () => { title: "legacy", summary: { additions: 5, deletions: 5 }, }) - }).pipe(Effect.provide(remappedStorage(tmp))) + }).pipe(Effect.provide(injectedStorage(tmp))) // kilocode_change expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2") }), @@ -270,7 +247,7 @@ describe("Storage", () => { role: "user", text: "hello", }) - }).pipe(Effect.provide(remappedStorage(tmp))) + }).pipe(Effect.provide(injectedStorage(tmp))) // kilocode_change expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2") }), @@ -288,7 +265,7 @@ describe("Storage", () => { yield* Effect.gen(function* () { const svc = yield* Storage.Service expect(yield* svc.list(["project"])).toEqual([]) - }).pipe(Effect.provide(remappedStorage(tmp))) + }).pipe(Effect.provide(injectedStorage(tmp))) // kilocode_change const exit = yield* fs.access(path.join(storage, "migration")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index bd8cb74c23e..b1bef17c648 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -15,18 +15,11 @@ describe("websearch provider", () => { }) test("supports an operational override", () => { - const original = process.env.KILO_WEBSEARCH_PROVIDER - - try { - process.env.KILO_WEBSEARCH_PROVIDER = "parallel" - expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel") - - process.env.KILO_WEBSEARCH_PROVIDER = "exa" - expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") - } finally { - if (original === undefined) delete process.env.KILO_WEBSEARCH_PROVIDER - else process.env.KILO_WEBSEARCH_PROVIDER = original - } + // kilocode_change start - the override is resolved via Env.Service by the tool and passed in + expect(selectWebSearchProvider(SESSION_ID, undefined, "parallel")).toBe("parallel") + expect(selectWebSearchProvider(SESSION_ID, undefined, "exa")).toBe("exa") + expect(selectWebSearchProvider(SESSION_ID, undefined, "bogus")).toBe(selectWebSearchProvider(SESSION_ID)) + // kilocode_change end }) test("routes to Exa when the Exa flag is enabled", () => { diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index b1b8201d841..f73579b55d7 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.21", + "version": "7.4.22", "description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 087fa49c04a..cda5c215c0a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 9807f4aac09..396e2d4da87 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -19,5 +19,5 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" }, - "version": "7.4.21" + "version": "7.4.22" } diff --git a/packages/schema/package.json b/packages/schema/package.json index 9c32ab9c3a5..ce0dd8c07bf 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -19,5 +19,5 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" }, - "version": "7.4.21" + "version": "7.4.22" } diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts index 05222d53981..654784e433d 100644 --- a/packages/schema/src/revert.ts +++ b/packages/schema/src/revert.ts @@ -20,5 +20,6 @@ export const State = Schema.Struct({ snapshot: Schema.String.pipe(optional), diff: Schema.String.pipe(optional), files: Schema.Array(FileDiff).pipe(optional), + workspace: Schema.Literals(["restored", "snapshots-disabled", "unavailable"]).pipe(optional), // kilocode_change }).annotate({ identifier: "Revert.State" }) export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 435ddb058b5..77b2927fb3b 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -578,6 +578,7 @@ const SessionRevert = Schema.Struct({ partID: optional(PartID), snapshot: optional(Schema.String), diff: optional(Schema.String), + workspace: optional(Schema.Literals(["restored", "snapshots-disabled", "unavailable"])), // kilocode_change }) const SessionModel = Schema.Struct({ diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts index c42ea5821e1..522e70dfa30 100644 --- a/packages/schema/src/worktree-event.ts +++ b/packages/schema/src/worktree-event.ts @@ -19,4 +19,14 @@ export const Failed = Event.define({ }, }) -export const Definitions = Event.inventory(Ready, Failed) +// kilocode_change start - fires after the worktree's start script finishes, unlike Ready +export const SetupReady = Event.define({ + type: "worktree.setup.ready", + schema: { + name: Schema.String, + branch: optional(Schema.String), + }, +}) +// kilocode_change end + +export const Definitions = Event.inventory(Ready, Failed, SetupReady) // kilocode_change diff --git a/packages/schema/test/kilocode/revert-workspace.test.ts b/packages/schema/test/kilocode/revert-workspace.test.ts new file mode 100644 index 00000000000..cded38bac1e --- /dev/null +++ b/packages/schema/test/kilocode/revert-workspace.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Project } from "../../src/project" +import { Revert } from "../../src/revert" +import { SessionV1 } from "../../src/session-v1" +import { SessionID } from "../../src/session-id" +import { SessionMessage } from "../../src/session-message" + +describe("revert workspace outcomes", () => { + test("preserves the current revert state outcome", () => { + const encoded = Schema.encodeSync(Revert.State)({ + messageID: SessionMessage.ID.make("msg_test"), + workspace: "restored", + }) + + expect(encoded.workspace).toBe("restored") + }) + + test("preserves the legacy session update outcome", () => { + const sessionID = SessionID.descending("ses_test") + const encoded = Schema.encodeSync(SessionV1.Event.Updated.data)({ + sessionID, + info: { + id: sessionID, + slug: "test", + projectID: Project.ID.make("project_test"), + directory: "/repo", + title: "Test", + version: "7.4.21", + time: { created: 1, updated: 1 }, + revert: { messageID: SessionV1.MessageID.make("msg_test"), workspace: "restored" }, + }, + }) + + expect(encoded.info.revert?.workspace).toBe("restored") + }) +}) diff --git a/packages/script/package.json b/packages/script/package.json index a3dd3de32a8..4cb5f00a0eb 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.4.21", + "version": "7.4.22", "peerDependencies": {} } diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 1da11d93d2e..66efb5d1369 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -23,5 +23,5 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" }, - "version": "7.4.21" + "version": "7.4.22" } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 46302dceea6..74af1b864e0 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 965b7b43c24..ac28034a203 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -8227,13 +8227,14 @@ export class Kilocode extends HeyApiClient { /** * Remove a custom agent * - * Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state. + * Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state. */ public removeAgent( parameters?: { directory?: string workspace?: string name?: string + scope?: "global" | "project" }, options?: Options, ) { @@ -8245,6 +8246,7 @@ export class Kilocode extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "name" }, + { in: "body", key: "scope" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 14d6826fb83..b4d93759830 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -91,6 +91,7 @@ export type Event = | EventWorkspaceStatus1 | EventWorktreeReady1 | EventWorktreeFailed1 + | EventWorktreeSetupReady1 | EventServerConnected1 | EventGlobalDisposed1 | EventGlobalConfigUpdated1 @@ -209,6 +210,7 @@ export type Event = | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed + | EventWorktreeSetupReady | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated @@ -532,6 +534,7 @@ export type Session = { partID?: string snapshot?: string diff?: string + workspace?: "restored" | "snapshots-disabled" | "unavailable" } } @@ -1253,6 +1256,7 @@ export type GlobalEvent = { | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed + | EventWorktreeSetupReady | EventServerConnected | EventGlobalDisposed | EventGlobalConfigUpdated @@ -2112,6 +2116,14 @@ export type GlobalEvent = { message: string } } + | { + id: string + type: "worktree.setup.ready" + properties: { + name: string + branch?: string + } + } | { id: string type: "server.connected" @@ -3916,6 +3928,10 @@ export type CommitMessageNoChangesError = { message: string } +export type CommitMessageFailedError = { + message: string +} + export type ConfigOverlayResponse = { scope: "global" | "project" effective: Config @@ -4728,6 +4744,7 @@ export type V2Event = | WorkspaceStatus | WorktreeReady | WorktreeFailed + | WorktreeSetupReady | ServerConnected | GlobalDisposed | GlobalConfigUpdated @@ -5707,6 +5724,7 @@ export type RevertState = { snapshot?: string diff?: string files?: Array + workspace?: "restored" | "snapshots-disabled" | "unavailable" } export type EventSessionNextRevertStaged = { @@ -6187,6 +6205,15 @@ export type EventWorktreeFailed = { } } +export type EventWorktreeSetupReady = { + id: string + type: "worktree.setup.ready" + properties: { + name: string + branch?: string + } +} + export type EventServerConnected = { id: string type: "server.connected" @@ -9116,6 +9143,24 @@ export type WorktreeFailed = { } } +export type WorktreeSetupReady = { + id: string + metadata?: { + [key: string]: unknown + } + type: "worktree.setup.ready" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + name: string + branch?: string + } +} + export type ServerConnected = { id: string metadata?: { @@ -10155,6 +10200,15 @@ export type EventWorktreeFailed1 = { } } +export type EventWorktreeSetupReady1 = { + id: string + type: "worktree.setup.ready" + properties: { + name: string + branch?: string + } +} + export type EventServerConnected1 = { id: string type: "server.connected" @@ -15091,9 +15145,9 @@ export type CommitMessageGenerateErrors = { */ 400: EffectHttpApiErrorBadRequest | InvalidRequestError /** - * CommitMessageNoChangesError + * CommitMessageNoChangesError | CommitMessageFailedError */ - 422: CommitMessageNoChangesError + 422: CommitMessageNoChangesError | CommitMessageFailedError } export type CommitMessageGenerateError = CommitMessageGenerateErrors[keyof CommitMessageGenerateErrors] @@ -16610,6 +16664,7 @@ export type KilocodeRemoveSkillResponse = KilocodeRemoveSkillResponses[keyof Kil export type KilocodeRemoveAgentData = { body?: { name: string + scope?: "global" | "project" } path?: never query?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7c4f1e393f3..fc33e89cc17 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11421,11 +11421,18 @@ } }, "422": { - "description": "CommitMessageNoChangesError", + "description": "CommitMessageNoChangesError | CommitMessageFailedError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CommitMessageNoChangesError" + "anyOf": [ + { + "$ref": "#/components/schemas/CommitMessageNoChangesError" + }, + { + "$ref": "#/components/schemas/CommitMessageFailedError" + } + ] } } } @@ -15251,7 +15258,7 @@ } } }, - "description": "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", + "description": "Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state.", "summary": "Remove a custom agent", "requestBody": { "content": { @@ -15261,6 +15268,10 @@ "properties": { "name": { "type": "string" + }, + "scope": { + "type": "string", + "enum": ["global", "project"] } }, "required": ["name"], @@ -25904,6 +25915,9 @@ { "$ref": "#/components/schemas/EventWorktreeFailed1" }, + { + "$ref": "#/components/schemas/EventWorktreeSetupReady1" + }, { "$ref": "#/components/schemas/EventServerConnected1" }, @@ -26258,6 +26272,9 @@ { "$ref": "#/components/schemas/EventWorktreeFailed" }, + { + "$ref": "#/components/schemas/EventWorktreeSetupReady" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -27280,6 +27297,10 @@ }, "diff": { "type": "string" + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -29401,6 +29422,9 @@ { "$ref": "#/components/schemas/EventWorktreeFailed" }, + { + "$ref": "#/components/schemas/EventWorktreeSetupReady" + }, { "$ref": "#/components/schemas/EventServerConnected" }, @@ -32380,6 +32404,34 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["worktree.setup.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -37765,6 +37817,16 @@ "required": ["message"], "additionalProperties": false }, + "CommitMessageFailedError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + }, "ConfigOverlayResponse": { "type": "object", "properties": { @@ -40280,6 +40342,9 @@ { "$ref": "#/components/schemas/WorktreeFailed" }, + { + "$ref": "#/components/schemas/WorktreeSetupReady" + }, { "$ref": "#/components/schemas/ServerConnected" }, @@ -43982,6 +44047,10 @@ "items": { "$ref": "#/components/schemas/FileDiff" } + }, + "workspace": { + "type": "string", + "enum": ["restored", "snapshots-disabled", "unavailable"] } }, "required": ["messageID"], @@ -45395,6 +45464,33 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventWorktreeSetupReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.setup.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventServerConnected": { "type": "object", "properties": { @@ -54231,6 +54327,56 @@ "required": ["id", "type", "data"], "additionalProperties": false }, + "WorktreeSetupReady": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["worktree.setup.ready"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, "ServerConnected": { "type": "object", "properties": { @@ -57440,6 +57586,34 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventWorktreeSetupReady1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "type": { + "type": "string", + "enum": ["worktree.setup.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventServerConnected1": { "type": "object", "properties": { diff --git a/packages/server/package.json b/packages/server/package.json index 1faa6a21a44..d49cf76b2d6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "7.4.21", + "version": "7.4.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 87909faaaee..68f605afd9e 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "7.4.21", + "version": "7.4.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 653f82a133e..78af204a976 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -28,7 +28,7 @@ "@opencode-ai/session-ui": "workspace:*", "react-dom": "18.2.0" }, - "version": "7.4.21", + "version": "7.4.22", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/tui/package.json b/packages/tui/package.json index e49823ae602..85bc479f0fd 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "7.4.21", + "version": "7.4.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c96296caca7..b148b9c44f5 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -632,6 +632,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi category: "Workspace", hidden: !Flag.KILO_EXPERIMENTAL_WORKSPACES, slashName: "workspaces", + slashAliases: ["worktree", "worktrees"], // kilocode_change - `kilo --worktree` worktrees are workspaces too run: () => { dialog.replace(() => ) }, diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index 61b7f4c94ad..a89539b7a5e 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, type ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" +import { reconcile } from "solid-js/store" // kilocode_change import { useTheme } from "../context/theme" import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "./border" @@ -60,7 +61,7 @@ function init() { const toast = { show(options: ToastInput) { const toastOptions = { ...options, duration: options.duration ?? 5000 } - setStore("currentToast", toastOptions) + setStore("currentToast", reconcile(toastOptions)) // kilocode_change if (timeoutHandle) clearTimeout(timeoutHandle) // kilocode_change start timeoutHandle = null diff --git a/packages/tui/test/kilocode/toast.test.tsx b/packages/tui/test/kilocode/toast.test.tsx new file mode 100644 index 00000000000..be28b6792e6 --- /dev/null +++ b/packages/tui/test/kilocode/toast.test.tsx @@ -0,0 +1,37 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { ToastProvider, useToast } from "../../src/ui/toast" + +test("replacing a toast does not keep the previous title", async () => { + let toast: ReturnType + function Probe() { + toast = useToast() + return + } + + const app = await testRender(() => ( + + + + )) + + try { + toast!.show({ + title: "MCP Authentication Required", + message: `Server "foo" requires authentication.`, + variant: "warning", + duration: 0, + }) + toast!.show({ + variant: "info", + message: "Updating to v7.4.21...", + duration: 0, + }) + expect(toast!.currentToast?.title).toBeUndefined() + expect(toast!.currentToast?.message).toBe("Updating to v7.4.21...") + expect(toast!.currentToast?.variant).toBe("info") + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/ui/package.json b/packages/ui/package.json index 9da53ad7291..c5fb53ed5f9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.4.21", + "version": "7.4.22", "type": "module", "license": "MIT", "repository": { @@ -35,6 +35,7 @@ "exports": { "./package.json": "./package.json", "./*": "./src/components/*.tsx", + "./file-path": "./src/file-path.ts", "./session-diff": "./src/components/session-diff.ts", "./i18n/*": "./src/i18n/*.ts", "./pierre": "./src/pierre/index.ts", diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index ad25fbf2721..4d8955bd2a3 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -61,6 +61,24 @@ text-underline-offset: 2px; } + /* kilocode_change start — file-path links from markdown [text](path) */ + a.file-path-link { + color: var(--syntax-string); + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-weight: var(--font-weight-medium); + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + transition: color 0.15s ease; + } + + a.file-path-link:hover { + color: var(--text-interactive-base); + text-decoration-style: solid; + } + /* kilocode_change end */ + /* Lists */ ul, ol { diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index d8fca2341fd..b25786815d2 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -42,7 +42,11 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string // kilocode_change start -export type OpenFileFn = (filePath: string, line?: number, column?: number) => void +// The optional trailing sessionID scopes the open to the session the file +// reference was rendered for, so the extension resolves its workspace directory +// from that explicit id instead of whatever session is current when the click +// is processed (avoids opening the wrong worktree during a session switch). +export type OpenFileFn = (filePath: string, line?: number, column?: number, sessionID?: string) => void export type OpenDiffFn = (diff: { file: string @@ -57,7 +61,10 @@ export type OpenUrlFn = (url: string) => void export type OpenContentFn = (content: string, language?: string) => void // kilocode_change -export type ValidateFilesFn = (paths: string[]) => Promise // kilocode_change +// kilocode_change start: sessionID scopes validation to the session the +// candidates were rendered for, so the extension resolves its workspace +// directory from that explicit id instead of whatever session is current. +export type ValidateFilesFn = (sessionID: string, paths: string[]) => Promise // kilocode_change end export const { use: useData, provider: DataProvider } = createSimpleContext({ diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 51b2b4ab900..1a7fa727900 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -7,7 +7,13 @@ import { marked, type MarkedExtension, type Tokens, type TokenizerAndRendererExt // two-pass rendering design. import katex from "katex" import { bundledLanguages, type BundledLanguage } from "shiki" -import { parseFilePath } from "../file-path" // kilocode_change +import { + extractSuffix, + normalizeCandidatePath, + extractFilePathFromHref, + looksLikeCandidate, + escapeAttribute, +} from "../file-path" // kilocode_change import { createSimpleContext } from "./helper" import { markedCodeSpanBoundary } from "./marked-code-span" import { getSharedHighlighter, type ThemeRegistrationResolved } from "@pierre/diffs" // kilocode_change @@ -540,8 +546,6 @@ async function highlightCodeBlocks(html: string): Promise { export type NativeMarkdownParser = (markdown: string) => Promise -// kilocode_change: parseFilePath imported from ../file-path - // kilocode_change start: highlight cache for deferred highlighting /** FNV-1a hash — lightweight alternative to storing full source code in DOM attributes. */ @@ -706,24 +710,52 @@ export const createMarkedParser = (props: { nativeParser?: NativeMarkdownParser { renderer: { link({ href, title, text }) { - const titleAttr = title ? ` title="${title}"` : "" - return `${text}` + // kilocode_change start: escape href/title for the attribute context — + // both come from raw model output, so a stray `"` must not break out of + // the attribute (defense-in-depth alongside the DOMPurify pass). The + // browser decodes the entities back, so the click handler still reads + // the original href. + const safeHref = href ? escapeAttribute(href) : "" + const titleAttr = title ? ` title="${escapeAttribute(title)}"` : "" + // file-path links get a distinct class for styling. Keep target/rel so + // the shared (opencode) consumer's navigation and security behavior is + // unchanged — Kilo's click handler intercepts these via preventDefault + // and opens the file instead. + const isFile = href ? extractFilePathFromHref(href) : undefined + if (isFile) { + return `${text}` + } + return `${text}` + // kilocode_change end }, - // kilocode_change start + // kilocode_change start — every code span is a file-link candidate. + // Post-render validation (via filesystem stat) will strip the class + // from candidates that don't correspond to real files. codespan({ text }) { - const file = parseFilePath(text) const escaped = text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") - if (file) { - const lineAttr = file.line ? ` data-file-line="${file.line}"` : "" - const colAttr = file.column ? ` data-file-col="${file.column}"` : "" - return `${escaped}` + // Skip obvious non-paths: contains spaces, URLs, or is empty + if (!text || text.includes(" ") || text.includes("://")) { + return `${escaped}` } - return `${escaped}` + const { candidate, line, column } = extractSuffix(text) + // A candidate just needs to be a bare token (no code punctuation) — + // extensionless files (`install`, `run-script`) qualify too. The + // post-render filesystem check is authoritative, so non-files like + // `useState` are validated away; see looksLikeCandidate. + if (!looksLikeCandidate(candidate)) { + return `${escaped}` + } + // Escape the candidate for the attribute — it's derived from raw + // model output, so a stray `"` must not break out of the attribute. + const normalized = escapeAttribute(normalizeCandidatePath(candidate)) + const lineAttr = line ? ` data-file-line="${line}"` : "" + const colAttr = column ? ` data-file-col="${column}"` : "" + return `${escaped}` }, code({ text, lang }) { const escaped = text diff --git a/packages/ui/src/file-path.test.ts b/packages/ui/src/file-path.test.ts index a2df1f15f06..2417c6c4665 100644 --- a/packages/ui/src/file-path.test.ts +++ b/packages/ui/src/file-path.test.ts @@ -1,189 +1,197 @@ // kilocode_change - new file import { describe, expect, it } from "bun:test" -import { parseFilePath, extractFilePathFromHref } from "./file-path" +import { + extractSuffix, + normalizeCandidatePath, + extractFilePathFromHref, + looksLikeFilePath, + looksLikeCandidate, + escapeAttribute, +} from "./file-path" -describe("parseFilePath", () => { - describe("Unix paths", () => { - it("bare filename", () => { - expect(parseFilePath("foo.ts")).toEqual({ path: "foo.ts", line: undefined, column: undefined }) - }) - - it("relative path", () => { - expect(parseFilePath("src/index.ts")).toEqual({ path: "src/index.ts", line: undefined, column: undefined }) - }) - - it("dot-relative path", () => { - expect(parseFilePath("./src/foo.ts")).toEqual({ path: "./src/foo.ts", line: undefined, column: undefined }) - }) - - it("parent-relative path", () => { - expect(parseFilePath("../lib/bar.ts")).toEqual({ path: "../lib/bar.ts", line: undefined, column: undefined }) - }) - - it("absolute path", () => { - expect(parseFilePath("/Users/dev/project/main.ts")).toEqual({ - path: "/Users/dev/project/main.ts", - line: undefined, - column: undefined, - }) - }) - - it("with line number", () => { - expect(parseFilePath("src/foo.ts:42")).toEqual({ path: "src/foo.ts", line: 42, column: undefined }) - }) - - it("with line and column", () => { - expect(parseFilePath("src/foo.ts:42:10")).toEqual({ path: "src/foo.ts", line: 42, column: 10 }) - }) - - it("deeply nested path", () => { - expect(parseFilePath("packages/ui/src/context/marked.tsx")).toEqual({ - path: "packages/ui/src/context/marked.tsx", - line: undefined, - column: undefined, - }) - }) - - it("path with @ scope", () => { - expect(parseFilePath("@scope/pkg/index.js")).toEqual({ - path: "@scope/pkg/index.js", - line: undefined, - column: undefined, - }) - }) - - it("dotfile with path", () => { - expect(parseFilePath("src/.eslintrc.json")).toEqual({ - path: "src/.eslintrc.json", - line: undefined, - column: undefined, - }) - }) +describe("extractSuffix", () => { + it("no suffix", () => { + expect(extractSuffix("src/foo.ts")).toEqual({ candidate: "src/foo.ts" }) }) - describe("Windows paths", () => { - it("drive letter with backslash", () => { - expect(parseFilePath("C:\\Users\\dev\\file.ts")).toEqual({ - path: "C:\\Users\\dev\\file.ts", - line: undefined, - column: undefined, - }) - }) - - it("drive letter with forward slash", () => { - expect(parseFilePath("C:/Users/dev/file.ts")).toEqual({ - path: "C:/Users/dev/file.ts", - line: undefined, - column: undefined, - }) - }) - - it("lowercase drive", () => { - expect(parseFilePath("d:\\projects\\app.tsx")).toEqual({ - path: "d:\\projects\\app.tsx", - line: undefined, - column: undefined, - }) - }) - - it("UNC path", () => { - expect(parseFilePath("\\\\server\\share\\file.ts")).toEqual({ - path: "\\\\server\\share\\file.ts", - line: undefined, - column: undefined, - }) - }) - - it("Windows path with line number", () => { - expect(parseFilePath("C:\\src\\file.ts:12")).toEqual({ path: "C:\\src\\file.ts", line: 12, column: undefined }) - }) - - it("Windows path with line and column", () => { - expect(parseFilePath("C:\\src\\file.ts:12:5")).toEqual({ path: "C:\\src\\file.ts", line: 12, column: 5 }) - }) + it("line only", () => { + expect(extractSuffix("src/foo.ts:42")).toEqual({ candidate: "src/foo.ts", line: 42 }) }) - describe("rejects non-paths", () => { - it("URL with protocol", () => { - expect(parseFilePath("https://example.com/path.html")).toBeUndefined() - }) + it("line and column", () => { + expect(extractSuffix("src/foo.ts:42:10")).toEqual({ candidate: "src/foo.ts", line: 42, column: 10 }) + }) - it("text with spaces", () => { - expect(parseFilePath("not a path.ts")).toBeUndefined() - }) + it("line range (extracts start line)", () => { + expect(extractSuffix("src/index.ts:1-30")).toEqual({ candidate: "src/index.ts", line: 1 }) + }) - it("bare word without extension", () => { - expect(parseFilePath("README")).toBeUndefined() - }) + it("line range with column", () => { + expect(extractSuffix("src/index.ts:10-21:5")).toEqual({ candidate: "src/index.ts", line: 10, column: 5 }) + }) - it("empty string", () => { - expect(parseFilePath("")).toBeUndefined() - }) + it("bare word", () => { + expect(extractSuffix("LICENSE")).toEqual({ candidate: "LICENSE" }) + }) - it("file:// URL", () => { - expect(parseFilePath("file:///foo/bar.ts")).toBeUndefined() - }) + it("dotfile", () => { + expect(extractSuffix(".gitignore")).toEqual({ candidate: ".gitignore" }) + }) - it("just a number", () => { - expect(parseFilePath("42")).toBeUndefined() - }) + it("Windows drive path with line", () => { + expect(extractSuffix("C:\\src\\file.ts:12")).toEqual({ candidate: "C:\\src\\file.ts", line: 12 }) + }) - it("path without extension", () => { - expect(parseFilePath("src/Makefile")).toBeUndefined() - }) + it("path with @ scope", () => { + expect(extractSuffix("@scope/pkg/index.js:5")).toEqual({ candidate: "@scope/pkg/index.js", line: 5 }) + }) + + it("empty string", () => { + expect(extractSuffix("")).toEqual({ candidate: "" }) + }) +}) + +describe("normalizeCandidatePath", () => { + it("bare filename gets ./ prefix", () => { + expect(normalizeCandidatePath("LICENSE")).toBe("./LICENSE") + }) + + it("bare relative path gets ./ prefix", () => { + expect(normalizeCandidatePath("src/foo.ts")).toBe("./src/foo.ts") + }) + + it("dotfile gets ./ prefix", () => { + expect(normalizeCandidatePath(".gitignore")).toBe("./.gitignore") + }) + + it("./ prefix preserved", () => { + expect(normalizeCandidatePath("./LICENSE")).toBe("./LICENSE") + }) + + it("../ prefix preserved", () => { + expect(normalizeCandidatePath("../lib/bar.ts")).toBe("../lib/bar.ts") + }) + + it("absolute unix path preserved", () => { + expect(normalizeCandidatePath("/usr/bin/env")).toBe("/usr/bin/env") + }) + + it("Windows drive path preserved", () => { + expect(normalizeCandidatePath("C:\\src\\file.ts")).toBe("C:\\src\\file.ts") + }) + + it("UNC path preserved", () => { + expect(normalizeCandidatePath("\\\\server\\share")).toBe("\\\\server\\share") + }) + + it("preserves a/ as a real workspace directory", () => { + expect(normalizeCandidatePath("a/src/app.ts")).toBe("./a/src/app.ts") + }) + + it("preserves b/ as a real workspace directory", () => { + expect(normalizeCandidatePath("b/src/app.ts")).toBe("./b/src/app.ts") + }) + + it("Windows forward-slash drive path preserved", () => { + expect(normalizeCandidatePath("C:/src/file.ts")).toBe("C:/src/file.ts") + }) + + it("single letter not treated as diff prefix", () => { + // "c/foo" should NOT strip "c/" — only "a/" and "b/" are diff prefixes + expect(normalizeCandidatePath("c/foo.ts")).toBe("./c/foo.ts") }) }) describe("extractFilePathFromHref", () => { describe("accepts file-like hrefs", () => { it("bare filename", () => { - expect(extractFilePathFromHref("AGENTS.md")).toBe("AGENTS.md") + expect(extractFilePathFromHref("AGENTS.md")).toEqual({ path: "AGENTS.md" }) + }) + + it("common extensionless workspace filename", () => { + expect(extractFilePathFromHref("LICENSE")).toEqual({ path: "LICENSE" }) + expect(extractFilePathFromHref("Dockerfile")).toEqual({ path: "Dockerfile" }) + expect(extractFilePathFromHref("Makefile")).toEqual({ path: "Makefile" }) }) it("relative path", () => { - expect(extractFilePathFromHref("src/foo.ts")).toBe("src/foo.ts") + expect(extractFilePathFromHref("src/foo.ts")).toEqual({ path: "src/foo.ts" }) }) it("dot-relative path", () => { - expect(extractFilePathFromHref("./README.md")).toBe("./README.md") + expect(extractFilePathFromHref("./README.md")).toEqual({ path: "./README.md" }) }) it("parent-relative path", () => { - expect(extractFilePathFromHref("../docs/guide.md")).toBe("../docs/guide.md") - }) - - it("path with multiple extensions", () => { - expect(extractFilePathFromHref("config.test.ts")).toBe("config.test.ts") + expect(extractFilePathFromHref("../docs/guide.md")).toEqual({ path: "../docs/guide.md" }) }) + }) + describe("file:// URLs return the decoded path", () => { it("file:// URL on Unix", () => { - expect(extractFilePathFromHref("file:///foo/bar.ts")).toBe("/foo/bar.ts") + expect(extractFilePathFromHref("file:///foo/bar.ts")).toEqual({ path: "/foo/bar.ts" }) }) it("file:// URL with Windows drive", () => { - expect(extractFilePathFromHref("file:///C:/Users/dev/file.ts")).toBe("C:/Users/dev/file.ts") + expect(extractFilePathFromHref("file:///C:/Users/dev/file.ts")).toEqual({ path: "C:/Users/dev/file.ts" }) }) it("file:// URL with encoded characters", () => { - expect(extractFilePathFromHref("file:///foo%20bar/baz.ts")).toBe("/foo bar/baz.ts") + expect(extractFilePathFromHref("file:///foo%20bar/baz.ts")).toEqual({ path: "/foo bar/baz.ts" }) + }) + }) + + describe("extracts line and column", () => { + it("path with line", () => { + expect(extractFilePathFromHref("src/foo.ts:42")).toEqual({ path: "src/foo.ts", line: 42 }) + }) + + it("path with line and column", () => { + expect(extractFilePathFromHref("src/foo.ts:42:10")).toEqual({ path: "src/foo.ts", line: 42, column: 10 }) + }) + + it("path with line range", () => { + expect(extractFilePathFromHref("src/index.ts:1-30")).toEqual({ path: "src/index.ts", line: 1 }) + }) + + it("Windows path with line and column", () => { + expect(extractFilePathFromHref("C:\\src\\file.ts:12:5")).toEqual({ + path: "C:\\src\\file.ts", + line: 12, + column: 5, + }) }) }) describe("strips fragments and queries", () => { it("strips #fragment", () => { - expect(extractFilePathFromHref("AGENTS.md#worktrees")).toBe("AGENTS.md") + expect(extractFilePathFromHref("AGENTS.md#worktrees")).toEqual({ path: "AGENTS.md" }) }) it("strips ?query", () => { - expect(extractFilePathFromHref("README.md?plain=1")).toBe("README.md") + expect(extractFilePathFromHref("README.md?plain=1")).toEqual({ path: "README.md" }) }) - it("strips both fragment and query", () => { - expect(extractFilePathFromHref("docs/guide.md?v=2#section")).toBe("docs/guide.md") + it("preserves a/ as a real workspace directory", () => { + expect(extractFilePathFromHref("a/src/app.ts")).toEqual({ path: "a/src/app.ts" }) }) - it("strips fragment from path with directory", () => { - expect(extractFilePathFromHref("src/foo.ts#L42")).toBe("src/foo.ts") + it("preserves b/ as a real workspace directory with line", () => { + expect(extractFilePathFromHref("b/src/app.ts:42")).toEqual({ path: "b/src/app.ts", line: 42 }) + }) + }) + + describe("rejects relative hrefs that do not look like files", () => { + it("extensionless relative doc link", () => { + expect(extractFilePathFromHref("docs/getting-started")).toBeUndefined() + }) + + it("extensionless absolute docs path", () => { + expect(extractFilePathFromHref("/docs")).toBeUndefined() + }) + + it("unknown extensionless filename", () => { + expect(extractFilePathFromHref("README")).toBeUndefined() }) }) @@ -217,30 +225,89 @@ describe("extractFilePathFromHref", () => { }) }) - describe("rejects anchors and non-file values", () => { + describe("rejects anchors and empty", () => { it("pure anchor", () => { expect(extractFilePathFromHref("#section")).toBeUndefined() }) - it("anchor with nested path", () => { - expect(extractFilePathFromHref("#/some/path")).toBeUndefined() - }) - it("empty string", () => { expect(extractFilePathFromHref("")).toBeUndefined() }) + }) - it("no extension (bare word)", () => { - expect(extractFilePathFromHref("README")).toBeUndefined() + describe("Windows drive letter not treated as scheme", () => { + it("C: drive path accepted", () => { + expect(extractFilePathFromHref("C:\\Users\\dev\\file.ts")).toEqual({ path: "C:\\Users\\dev\\file.ts" }) }) - it("directory-only path (no extension)", () => { - expect(extractFilePathFromHref("src/components/")).toBeUndefined() - }) - - it("fragment-only after stripping resolves to empty", () => { - // href is "#foo" — starts with # so rejected - expect(extractFilePathFromHref("#foo.md")).toBeUndefined() + it("D: drive path with line", () => { + expect(extractFilePathFromHref("D:\\projects\\app.tsx:10")).toEqual({ + path: "D:\\projects\\app.tsx", + line: 10, + }) }) }) }) + +describe("looksLikeFilePath", () => { + it("accepts names with an extension", () => { + expect(looksLikeFilePath("src/foo.ts")).toBe(true) + expect(looksLikeFilePath("README.md")).toBe(true) + expect(looksLikeFilePath("./a/b/c.tsx")).toBe(true) + }) + + it("accepts known extensionless filenames", () => { + expect(looksLikeFilePath("Dockerfile")).toBe(true) + expect(looksLikeFilePath("LICENSE")).toBe(true) + expect(looksLikeFilePath("path/to/Makefile")).toBe(true) + }) + + it("rejects bare identifiers and extensionless paths", () => { + expect(looksLikeFilePath("useState")).toBe(false) + expect(looksLikeFilePath("null")).toBe(false) + expect(looksLikeFilePath("src/foo")).toBe(false) + expect(looksLikeFilePath("README")).toBe(false) + }) +}) + +describe("looksLikeCandidate", () => { + it("accepts extensionless files, separator paths, and bare tokens", () => { + // These are the cases a strict extension check misses; the filesystem check + // downstream decides which are real. + expect(looksLikeCandidate("install")).toBe(true) + expect(looksLikeCandidate("run-script")).toBe(true) + expect(looksLikeCandidate(".kilo/run-script")).toBe(true) + expect(looksLikeCandidate("src/foo")).toBe(true) + expect(looksLikeCandidate("README")).toBe(true) + expect(looksLikeCandidate("src/foo.ts")).toBe(true) + // A non-file bare identifier is still a candidate — it's just probed and + // demoted, rather than rejected up front. + expect(looksLikeCandidate("useState")).toBe(true) + }) + + it("rejects empty spans and code expressions with punctuation", () => { + expect(looksLikeCandidate("")).toBe(false) + expect(looksLikeCandidate("useState()")).toBe(false) + expect(looksLikeCandidate("arr[i]")).toBe(false) + expect(looksLikeCandidate("a = b")).toBe(false) + expect(looksLikeCandidate("{ a }")).toBe(false) + expect(looksLikeCandidate("a < b")).toBe(false) + }) +}) + +describe("escapeAttribute", () => { + it("escapes quotes, ampersands, and angle brackets", () => { + expect(escapeAttribute('a"b')).toBe("a"b") + expect(escapeAttribute("a&b")).toBe("a&b") + expect(escapeAttribute("ac")).toBe("a<b>c") + }) + + it("escapes & before introducing entities (no double-escaping)", () => { + expect(escapeAttribute('"')).toBe(""") + expect(escapeAttribute('&"')).toBe("&"") + }) + + it("leaves ordinary paths untouched", () => { + expect(escapeAttribute("./src/foo.ts")).toBe("./src/foo.ts") + }) +}) diff --git a/packages/ui/src/file-path.ts b/packages/ui/src/file-path.ts index a3f7b9ae635..23649c7db52 100644 --- a/packages/ui/src/file-path.ts +++ b/packages/ui/src/file-path.ts @@ -1,42 +1,90 @@ // kilocode_change - new file -// Matches text that looks like a file path: -// - Unix: /foo/bar.ts, ./foo.ts, ../foo.ts, foo.ts -// - Windows drive: C:\foo\bar.ts, C:/foo/bar.ts -// - Windows UNC: \\server\share\file.ts -// Supports optional :line or :line:col suffix. -const FILE_PATH_UNIX_RE = - /^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ -const FILE_PATH_WIN_RE = /^((?:[a-zA-Z]:[/\\]|\\\\)(?:[^\\/]+[/\\])*[^\\/]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ - /** - * Parse an inline code span into a file path with optional line/column. - * Returns undefined when the text does not look like a file reference. - * - * Handles Unix paths (`/foo/bar.ts`, `./foo.ts`, `foo.ts`), - * Windows drive paths (`C:\foo\bar.ts`), and UNC paths (`\\server\share\file.ts`). + * Strip an optional :line[-endline][:col] suffix from a code span. + * Returns the candidate file path and optional line/column numbers. */ -export function parseFilePath(text: string): { path: string; line?: number; column?: number } | undefined { - if (text.includes("://")) return undefined - if (text.includes(" ")) return undefined - const match = FILE_PATH_UNIX_RE.exec(text) ?? FILE_PATH_WIN_RE.exec(text) - if (!match) return undefined - return { - path: match[1], - line: match[2] ? parseInt(match[2], 10) : undefined, - column: match[3] ? parseInt(match[3], 10) : undefined, - } +export function extractSuffix(text: string): { candidate: string; line?: number; column?: number } { + // Try :line:col first, then :line (with optional -endline range) + const m3 = /^(.+):(\d+)(?:-\d+)?:(\d+)$/.exec(text) + if (m3) return { candidate: m3[1], line: +m3[2], column: +m3[3] } + const m2 = /^(.+):(\d+)(?:-\d+)?$/.exec(text) + if (m2) return { candidate: m2[1], line: +m2[2] } + return { candidate: text } } -const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ +/** + * Normalize a candidate path for filesystem validation. + * Ensures the path has a ./ prefix if it's a bare relative path, + * so the extension can stat-check it against the workspace root. + */ +export function normalizeCandidatePath(path: string): string { + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return path + // Windows absolute paths (C:\...) — leave as-is + if (/^[a-zA-Z]:[/\\]/.test(path)) return path + // Windows UNC paths (\\server\...) — leave as-is + if (path.startsWith("\\\\")) return path + return `./${path}` +} + +// Matches a URI scheme but NOT a Windows drive letter (single char followed by colon). +const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]+:/ +const EXTENSIONLESS_FILES = new Set(["Dockerfile", "LICENSE", "Makefile"]) /** - * Extract a file path from a markdown link href, or return undefined - * when the href is a URL, anchor, scheme, or otherwise not a file reference. - * - * Strips `#fragment` and `?query` suffixes before returning the path. + * Strict heuristic for a markdown-link href (`[text](href)`). Link hrefs are + * decided at render time and get NO filesystem check, so a false positive would + * hijack a real navigation link (e.g. a `docs/getting-started` SPA route) and + * try to open a nonexistent file. Keep this conservative: only a basename with + * an extension or a known extensionless filename qualifies. */ -export function extractFilePathFromHref(href: string): string | undefined { +export function looksLikeFilePath(path: string): boolean { + const name = path.split(/[\\/]/).pop() ?? path + return name.includes(".") || EXTENSIONLESS_FILES.has(name) +} + +// Code punctuation that shows up in expressions but never in a bare file path, +// so a span containing any of these is prose/code, not a file reference. Quotes +// are intentionally omitted so escapeAttribute still guards the rare path with a +// stray quote rather than the span being silently dropped. +const NON_PATH_CHAR = /[()[\]{}<>;=,\s]/ + +/** + * Permissive heuristic for an inline code span. Unlike a link href, a code-span + * candidate IS validated against the filesystem afterward, so being liberal here + * is safe: a non-file like `useState` is probed once and demoted back to plain + * code. This lets extensionless files (`install`, `run-script`, `configure`) and + * separator paths become clickable, which a strict "must have an extension" rule + * would miss. Spans that are empty or contain code punctuation (`useState()`, + * `arr[i]`, `a = b`) are rejected up front so the obvious non-paths never probe. + * The extra non-file probes this admits are deduped/batched and cached as + * bounded negatives (see file-link-validator.ts) so they don't evict confirmed + * files, and each candidate is only probed once its streamed path settles (see + * the per-element debounce in message-part.tsx) to cap volume. + */ +export function looksLikeCandidate(path: string): boolean { + return !!path && !NON_PATH_CHAR.test(path) +} + +/** + * Escape a string for safe interpolation into a double-quoted HTML attribute. + * Candidate paths come from raw model output, so escaping prevents a value + * containing `"` (or `&`/`<`/`>`) from breaking out of the attribute — + * defense-in-depth alongside the DOMPurify pass the markdown already runs. + */ +export function escapeAttribute(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") +} + +/** + * Extract a file path (with optional line/column) from a markdown link href, + * or return undefined when the href is a URL, anchor, scheme, or otherwise + * not a file reference. + * + * Strips `#fragment` and `?query` suffixes, then parses an optional + * `:line` or `:line:column` suffix from the remaining path. + */ +export function extractFilePathFromHref(href: string): { path: string; line?: number; column?: number } | undefined { if (!href) return undefined // Handle file:// URLs — extract the path component and decode it if (href.startsWith("file://")) { @@ -52,7 +100,7 @@ export function extractFilePathFromHref(href: string): string | undefined { decoded.charCodeAt(0) === 47 /* / */ && decoded.charCodeAt(2) === 58 /* : */ && ((c1 >= 65 && c1 <= 90) /* A-Z */ || (c1 >= 97 && c1 <= 122)) /* a-z */ - return isWindowsDrive ? decoded.slice(1) : decoded + return { path: isWindowsDrive ? decoded.slice(1) : decoded } } catch { return undefined } @@ -64,7 +112,7 @@ export function extractFilePathFromHref(href: string): string | undefined { // Strip fragment and query before treating as file path const cleaned = href.replace(/[#?].*$/, "") if (!cleaned) return undefined - // Must look like a file path (has a dot for extension) - if (!cleaned.includes(".")) return undefined - return cleaned + const { candidate, line, column } = extractSuffix(cleaned) + if (!looksLikeFilePath(candidate)) return undefined + return { path: candidate, line, column } } diff --git a/packages/ui/src/kilocode/markdown-bidi.test.ts b/packages/ui/src/kilocode/markdown-bidi.test.ts index 906f6ad6fc7..2b16edca1b2 100644 --- a/packages/ui/src/kilocode/markdown-bidi.test.ts +++ b/packages/ui/src/kilocode/markdown-bidi.test.ts @@ -68,16 +68,63 @@ describe("Markdown bidirectional rendering contract", () => { const parser = createMarkedParser({}) const html = await Promise.resolve( parser.parse( - ["متن با `inlineCode`", "", "```ts", "const value = 1", "```", "", "$$", "a = b + c", "$$"].join("\n"), + ["متن با `format()`", "", "```ts", "const value = 1", "```", "", "$$", "a = b + c", "$$"].join("\n"), ), ) - expect(html).toContain('inlineCode') + // `format()` contains code punctuation, so it is not a file-link candidate + // and renders as plain code with isolated dir. + expect(html).toContain('format()') expect(html).toContain('
')
     expect(html).toContain("const value = 1")
     expect(html.match(/ {
+    const parser = createMarkedParser({})
+    const withPath = await Promise.resolve(parser.parse("see `src/foo.ts:12:5`"))
+    expect(withPath).toContain('class="file-link-candidate"')
+    expect(withPath).toContain('data-file-candidate="./src/foo.ts"')
+    expect(withPath).toContain('data-file-line="12"')
+    expect(withPath).toContain('data-file-col="5"')
+
+    // Extensionless files (e.g. `install`) are candidates too — the filesystem
+    // check downstream decides whether they resolve to a real file.
+    const bare = await Promise.resolve(parser.parse("run `install` here"))
+    expect(bare).toContain('class="file-link-candidate"')
+    expect(bare).toContain('data-file-candidate="./install"')
+
+    // Spans with code punctuation are not candidates, so they never probe.
+    const plain = await Promise.resolve(parser.parse("call `useState()` here"))
+    expect(plain).toContain('useState()')
+    expect(plain).not.toContain("file-link-candidate")
+  })
+
+  test("escapes the candidate attribute so a quote can't break out", async () => {
+    const parser = createMarkedParser({})
+    // Path-like (has an extension) but contains a quote from raw model output.
+    const html = await Promise.resolve(parser.parse('`a".ts`'))
+    expect(html).toContain('data-file-candidate="./a".ts"')
+    // The quote must be escaped, not left raw to break the attribute and
+    // inject a new one.
+    expect(html).not.toContain('data-file-candidate="./a".ts"')
+  })
+
+  test("tags file-path markdown links but keeps target/rel", async () => {
+    const parser = createMarkedParser({})
+    const html = await Promise.resolve(parser.parse("[open](src/foo.ts)"))
+    expect(html).toContain('class="external-link file-path-link"')
+    expect(html).toContain('target="_blank"')
+    expect(html).toContain('rel="noopener noreferrer"')
+  })
+
+  test("escapes an ampersand in a link href so it can't break the attribute", async () => {
+    const parser = createMarkedParser({})
+    const html = await Promise.resolve(parser.parse("[q](https://e.com/x?a=1&b=2)"))
+    expect(html).toContain('href="https://e.com/x?a=1&b=2"')
+    expect(html).not.toContain('href="https://e.com/x?a=1&b=2"')
+  })
+
   test("updates streaming code highlight in place while preserving direction", () => {
     const win = new Window()
     const scope = globalThis as typeof globalThis & {
diff --git a/script/architecture-allowlist.json b/script/architecture-allowlist.json
index 1b2f28b7dd7..9ff61dc3bda 100644
--- a/script/architecture-allowlist.json
+++ b/script/architecture-allowlist.json
@@ -24,10 +24,7 @@
     },
     "kilo-tool-process-env": {
       "description": "Direct process.env reads in Kilo-owned tools. Target: pass configuration explicitly via Tool.Context or Env.Service.",
-      "allowed": {
-        "packages/opencode/src/tool/mcp-websearch.ts": { "count": 2, "owner": "tool-runtime", "reason": "Top-level EXA_API_KEY snapshot" },
-        "packages/opencode/src/tool/websearch.ts": { "count": 4, "owner": "tool-runtime", "reason": "KILO_WEBSEARCH_PROVIDER, PARALLEL_API_KEY, EXA_API_KEY reads" }
-      }
+      "allowed": {}
     }
   }
 }
diff --git a/script/upstream/package.json b/script/upstream/package.json
index d27b5b7846d..37b0a8f4e67 100644
--- a/script/upstream/package.json
+++ b/script/upstream/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kilocode/upstream-merge",
-  "version": "7.4.21",
+  "version": "7.4.22",
   "private": true,
   "type": "module",
   "description": "Scripts for automating upstream opencode merges into Kilo",