diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index cd1c053433..68525ac53e 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -57,7 +57,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md new file mode 100644 index 0000000000..8a8e57d1bf --- /dev/null +++ b/.opencode/command/changelog.md @@ -0,0 +1,5 @@ +go through each PR merged since the last tag + +for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md + +once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 65fd596316..1471ec3324 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,6 +1,19 @@ /// -// import { Octokit } from "@octokit/rest" import { tool } from "@kilocode/plugin" +const TEAM = { + desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], + zen: ["fwang", "MrMushrooooom"], + tui: ["thdxr", "kommander", "rekram1-node"], + core: ["thdxr", "rekram1-node", "jlongster"], + docs: ["R44VC0RP"], + windows: ["Hona"], +} as const + +const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] + +function pick(items: readonly T[]) { + return items[Math.floor(Math.random() * items.length)]! +} function getIssueNumber(): number { const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) diff --git a/bun.lock b/bun.lock index 8269b52579..efa083e2d7 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "catalog:", + "@solid-primitives/timer": "1.4.4", "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", @@ -377,8 +378,8 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.88", - "@opentui/solid": "0.1.88", + "@opentui/core": "0.1.90", + "@opentui/solid": "0.1.90", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -397,7 +398,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "5.2.2", + "gitlab-ai-provider": "5.3.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -409,6 +410,7 @@ "minimatch": "10.2.5", "open": "10.1.2", "opencode-gitlab-auth": "2.0.0", + "opencode-poe-auth": "0.0.1", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", "remeda": "catalog:", @@ -622,7 +624,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.9", + "@types/bun": "1.3.11", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", @@ -1414,21 +1416,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], - "@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="], + "@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="], - "@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="], + "@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1786,6 +1788,8 @@ "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="], + "@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="], + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], @@ -1954,7 +1958,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -2404,7 +2408,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -3034,7 +3038,7 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="], + "gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -3610,6 +3614,8 @@ "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="], + "opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="], + "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], @@ -3736,6 +3742,8 @@ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "poe-oauth": ["poe-oauth@0.0.6", "", {}, "sha512-dI8xrVl7RSFh0B+cb4GGuCjIfGtDT9VpbpVkP0UKcunpXF0eFw+6GencoJ7k+E02ZYqopBQApMVWGq70/GP69w=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -4704,6 +4712,10 @@ "@opencode-ai/desktop-electron/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/plugin/@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="], + + "@opencode-ai/plugin/@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="], + "@opencode-ai/storybook/@storybook/addon-a11y": ["@storybook/addon-a11y@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.3.5" } }, "sha512-5k6lpgfIeLxvNhE8v3wEzdiu73ONKjF4gmH1AHvfqYd8kIVzQJai0KCDxgvqNncXHQhIWkaf1fg6+9hKaYJyaw=="], "@opencode-ai/storybook/@storybook/addon-docs": ["@storybook/addon-docs@10.3.5", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.3.5", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.3.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.3.5" } }, "sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA=="], @@ -5068,6 +5080,8 @@ "nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], + "opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], @@ -5366,6 +5380,20 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@opencode-ai/plugin/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="], + + "@opencode-ai/plugin/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="], + + "@opencode-ai/plugin/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="], + + "@opencode-ai/plugin/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="], + + "@opencode-ai/plugin/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="], + + "@opencode-ai/plugin/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="], + + "@opencode-ai/plugin/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + "@opencode-ai/storybook/@storybook/addon-docs/@storybook/csf-plugin": ["@storybook/csf-plugin@10.3.5", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.3.5", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw=="], "@opencode-ai/storybook/@storybook/addon-docs/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.3.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.3.5" } }, "sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g=="], @@ -5822,6 +5850,8 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/plugin/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], diff --git a/package.json b/package.json index c8532713f2..9299c4981c 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "@kilocode/kilo", + "name": "opencode", "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.10", + "packageManager": "bun@1.3.11", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", @@ -15,7 +15,6 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "extension": "bun --cwd packages/kilo-vscode script/launch.ts", "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { @@ -25,7 +24,7 @@ ], "catalog": { "@effect/platform-node": "4.0.0-beta.35", - "@types/bun": "1.3.9", + "@types/bun": "1.3.11", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", @@ -107,10 +106,10 @@ "electron" ], "overrides": { - "effect": "catalog:", - "@effect/platform-node-shared": "4.0.0-beta.35", "@types/bun": "catalog:", "@types/node": "catalog:", + "effect": "catalog:", + "@effect/platform-node-shared": "4.0.0-beta.35", "path-to-regexp": ">=8.4.0", "picomatch": ">=2.3.2", "defu": "6.1.6", diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index e09726208f..f1a2a5a940 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true) } -export async function openPalette(page: Page) { +export async function openPalette(page: Page, key = "K") { await defocus(page) - await page.keyboard.press(`${modKey}+P`) + await page.keyboard.press(`${modKey}+${key}`) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts index 3ccfd7a925..4c701fab27 100644 --- a/packages/app/e2e/app/palette.spec.ts +++ b/packages/app/e2e/app/palette.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openPalette } from "../actions" +import { closeDialog, openPalette } from "../actions" test("search palette opens and closes", async ({ page, gotoSession }) => { await gotoSession() @@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => { await page.keyboard.press("Escape") await expect(dialog).toHaveCount(0) }) + +test("search palette also opens with cmd+p", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openPalette(page, "P") + + await closeDialog(page, dialog) + await expect(dialog).toHaveCount(0) +}) diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts index 36f83da80e..5c4176d62b 100644 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page await page.keyboard.type(draft) await wait(page, draft) - await edge(page, "start") + // Clear the draft before navigating history (ArrowUp only works when prompt is empty) + await prompt.fill("") + await wait(page, "") + await page.keyboard.press("ArrowUp") await wait(page, second) @@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page await wait(page, second) await page.keyboard.press("ArrowDown") - await wait(page, draft) + await wait(page, "") }) }) diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index 5789dc0eb0..4fc50b68d7 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => { await expect(keybindButton).toBeVisible() const initialKeybind = await keybindButton.textContent() - expect(initialKeybind).toContain("P") + expect(initialKeybind).toContain("K") await keybindButton.click() await expect(keybindButton).toHaveText(/press/i) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index d10fca0e49..1317d2bb68 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -1,6 +1,16 @@ import { test, expect } from "../fixtures" -import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" +import { + defocus, + cleanupSession, + cleanupTestProject, + closeSidebar, + createTestProject, + hoverSessionItem, + openSidebar, + waitSession, +} from "../actions" import { projectSwitchSelector } from "../selectors" +import { dirSlug } from "../utils" test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { const stamp = Date.now() @@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p await cleanupSession({ sdk, sessionID: two.id }) } }) + +test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const slug = dirSlug(other) + + try { + await withProject( + async () => { + await openSidebar(page) + + const project = page.locator(projectSwitchSelector(slug)).first() + const card = page.locator('[data-component="hover-card-content"]') + + await expect(project).toBeVisible() + await project.hover() + await expect(card.getByText(/recent sessions/i)).toBeVisible() + + await page.mouse.down() + await expect(card).toHaveCount(0) + await page.mouse.up() + + await waitSession(page, { directory: other }) + await expect(card).toHaveCount(0) + }, + { extra: [other] }, + ) + } finally { + await cleanupTestProject(other) + } +}) + +test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const slug = dirSlug(other) + + try { + await withProject( + async () => { + await openSidebar(page) + await defocus(page) + + const project = page.locator(projectSwitchSelector(slug)).first() + + await expect(project).toBeVisible() + + let hit = false + for (let i = 0; i < 20; i++) { + hit = await project.evaluate((el) => { + return el.matches(":focus") || !!el.parentElement?.matches(":focus") + }) + if (hit) break + await page.keyboard.press("Tab") + } + + expect(hit).toBe(true) + + await page.keyboard.press("Enter") + await waitSession(page, { directory: other }) + }, + { extra: [other] }, + ) + } finally { + await cleanupTestProject(other) + } +}) diff --git a/packages/app/package.json b/packages/app/package.json index c04542d92b..946940a888 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -51,6 +51,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "catalog:", + "@solid-primitives/timer": "1.4.4", "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a2453132d3..1f573b1eb3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" -import { ThemeProvider } from "@opencode-ai/ui/theme" +import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file" import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" import { HighlightsProvider } from "@/context/highlights" -import { LanguageProvider, useLanguage } from "@/context/language" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps) { +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) { void window.api?.setTitlebar?.({ mode }) }} > - + }> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 1920f8aae5..7ae6af90e8 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization } from "@kilocode/sdk/v2/client" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" @@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) - const methods = createMemo( - () => - globalSync.data.provider_auth[props.provider] ?? [ - { - type: "api", - label: language.t("provider.connect.method.apiKey"), - }, - ], + const fallback = createMemo(() => [ + { + type: "api" as const, + label: language.t("provider.connect.method.apiKey"), + }, + ]) + const [auth] = createResource( + () => props.provider, + async () => { + const cached = globalSync.data.provider_auth[props.provider] + if (cached) return cached + const res = await globalSDK.client.provider.auth() + if (!alive.value) return fallback() + globalSync.set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) + const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) { index: 0, }) - const prompts = createMemo(() => method()?.prompts ?? []) + const prompts = createMemo>(() => { + const value = method() + if (value?.type !== "oauth") return [] + return value.prompts ?? [] + }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] @@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) { listRef?.onKeyDown(e) } - onMount(() => { + let auto = false + createEffect(() => { + if (auto) return + if (loading()) return if (methods().length === 1) { + auto = true selectMethod(0) } }) @@ -575,6 +593,14 @@ export function DialogConnectProvider(props: { provider: string }) { + + + + + {language.t("provider.connect.status.inProgress")} + + + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 074e6e2d0e..19ba19add8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) @@ -1043,7 +1044,7 @@ export const PromptInput: Component = (props) => { return true } - const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ + const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1389,11 +1390,7 @@ export const PromptInput: Component = (props) => { class="hidden" onChange={(e) => { const list = e.currentTarget.files - if (list) { - for (const file of Array.from(list)) { - void addAttachment(file) - } - } + if (list) void addAttachments(Array.from(list)) e.currentTarget.value = "" }} /> @@ -1505,7 +1502,7 @@ export const PromptInput: Component = (props) => { > @@ -1537,7 +1534,7 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index eca508c6ce..fa9930f683 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const addAttachment = (file: File) => add(file) + const addAttachments = async (files: File[], toast = true) => { + let found = false + + for (const file of files) { + const ok = await add(file, false) + if (ok) found = true + } + + if (!found && files.length > 0 && toast) warn() + return found + } + const removeAttachment = (id: string) => { const current = prompt.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) @@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { event.preventDefault() event.stopPropagation() - const items = Array.from(clipboardData.items) - const fileItems = items.filter((item) => item.kind === "file") + const files = Array.from(clipboardData.items).flatMap((item) => { + if (item.kind !== "file") return [] + const file = item.getAsFile() + return file ? [file] : [] + }) - if (fileItems.length > 0) { - let found = false - for (const item of fileItems) { - const file = item.getAsFile() - if (!file) continue - const ok = await add(file, false) - if (ok) found = true - } - if (!found) warn() + if (files.length > 0) { + await addAttachments(files) return } @@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dropped = event.dataTransfer?.files if (!dropped) return - let found = false - for (const file of Array.from(dropped)) { - const ok = await add(file, false) - if (ok) found = true - } - if (!found && dropped.length > 0) warn() + await addAttachments(Array.from(dropped)) } onMount(() => { @@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { return { addAttachment, + addAttachments, removeAttachment, handlePaste, } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 4c2e2d8bec..ce09ae9217 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -49,6 +49,32 @@ describe("buildRequestParts", () => { expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) }) + test("keeps multiple uploaded attachments in order", () => { + const result = buildRequestParts({ + prompt: [{ type: "text", content: "check these", start: 0, end: 11 }], + context: [], + images: [ + { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + { + type: "image", + id: "img_2", + filename: "b.pdf", + mime: "application/pdf", + dataUrl: "data:application/pdf;base64,BBB", + }, + ], + text: "check these", + messageID: "msg_multi", + sessionID: "ses_multi", + sessionDirectory: "/repo", + }) + + const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:")) + + expect(files).toHaveLength(2) + expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"]) + }) + test("deduplicates context files when prompt already includes same path", () => { const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 37b5ce1962..5e9c2c66ea 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -126,7 +126,7 @@ describe("prompt-input history", () => { test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { const value = "a\nb\nc" - expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) + expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) @@ -135,11 +135,14 @@ describe("prompt-input history", () => { expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) - expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) + expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false) + expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true) + expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true) + expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index de62653211..79e8abc0d9 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin const atStart = position === 0 const atEnd = position === text.length if (inHistory) return atStart || atEnd - if (direction === "up") return position === 0 + if (direction === "up") return position === 0 && text.length === 0 return position === text.length } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index d4e6cdeb02..880c95f5f4 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -267,14 +267,14 @@ export function SessionContextTab() { return ( { scroll = el restoreScroll() }} onScroll={handleScroll} > - + {(stat) => [0])} value={stat.value()} />} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 7c6b807981..22858bfc04 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font } // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { + demoSoundState.run += 1 if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} + onHighlight={(option) => { + void loadFont().then((x) => x.ensureMonoFont(option?.value)) + }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 464522443f..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -54,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -162,6 +165,12 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) + let dialogRun = 0 + let dialogDead = false + onCleanup(() => { + dialogDead = true + dialogRun += 1 + }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -169,7 +178,7 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -300,7 +309,13 @@ export function StatusPopover() { dialog.show(() => , defaultServer.refresh)} + onClick={() => { + const run = ++dialogRun + void import("./dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => , defaultServer.refresh) + }) + }} > {language.t("status.popover.action.manageServers")} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { withAlpha } from "@opencode-ai/ui/theme/color" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" +import type { HexColor } from "@opencode-ai/ui/theme/types" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts index 4e38efd8da..d804195c40 100644 --- a/packages/app/src/context/command-keybind.test.ts +++ b/packages/app/src/context/command-keybind.test.ts @@ -40,4 +40,11 @@ describe("command keybind helpers", () => { expect(display.includes("Alt") || display.includes("⌥")).toBe(true) expect(formatKeybind("none")).toBe("") }) + + test("formatKeybind prefers the first combo", () => { + const display = formatKeybind("mod+k,mod+p") + + expect(display.includes("K") || display.includes("k")).toBe(true) + expect(display.includes("P") || display.includes("p")).toBe(false) + }) }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 5944289d77..3034ba07e8 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ import type { } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { - createContext, - getOwner, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + global: { + config: globalStore.config, + project: globalStore.project, + provider: globalStore.provider, + }, sdk, store: child[0], setStore: child[1], @@ -278,15 +275,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: queue.refresh, + refresh: () => { + if (recent) return + queue.refresh() + }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { + if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { - url: globalSDK.url, - }), - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) + bootingRoot = true + try { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + bootedAt = Date.now() + } finally { + bootingRoot = false + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 3d2f85bdb2..cba3d5adbc 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,73 +31,102 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: KiloClient - connectErrorTitle: string - connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const health = await input.globalSDK.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: input.connectErrorTitle, - description: input.connectErrorDescription, - }) - input.setGlobalStore("ready", true) - return - } - - const tasks = [ - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => - input.globalSDK.provider.auth().then((x) => { - input.setGlobalStore("provider_auth", x.data ?? {}) - }), - ), + const fast = [ + () => + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -111,6 +140,10 @@ function groupBySession(input: T[]) }, {}) } +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + export async function bootstrapDirectory(input: { directory: string sdk: KiloClient @@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string -}) { - if (input.store.status !== "complete") input.setStore("status", "loading") - - const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), - provider: () => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), - config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), + global: { + config: Config + project: Project[] + provider: ProviderListResponse } +}) { + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + if (seededProject) input.setStore("project", seededProject) + if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { + input.setStore("provider", input.global.provider) + } + if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { + input.setStore("config", input.global.config) + } + if (loading) input.setStore("status", "partial") - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + const fast = [ + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] + + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + const errs = errors(await runAll(fast)) + if (errs.length > 0) { + console.error("Failed to bootstrap instance", errs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(errs[0], input.translate), }) - input.setStore("status", "partial") - return } - if (input.store.status !== "complete") input.setStore("status", "partial") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } - Promise.all([ - input.sdk.path.get().then((x) => input.setStore("path", x.data!)), - input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), - input.loadSessions(input.directory), - input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), - input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - input.setStore("status", "complete") - }) + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 6b5e14ccde..b8ed8d37af 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] @@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: { } case "message.part.updated": { const part = (event.properties as { part: Part }).part + if (SKIP_PARTS.has(part.type)) break const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 9bf9d504c8..37d4158bd6 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,64 +1,13 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" -import { dict as ko } from "@/i18n/ko" -import { dict as de } from "@/i18n/de" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as da } from "@/i18n/da" -import { dict as ja } from "@/i18n/ja" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as ar } from "@/i18n/ar" -import { dict as no } from "@/i18n/no" -import { dict as br } from "@/i18n/br" -import { dict as th } from "@/i18n/th" -import { dict as bs } from "@/i18n/bs" -import { dict as nl } from "@/i18n/nl" -import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" -import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" -import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" -import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" -import { dict as uiDe } from "@opencode-ai/ui/i18n/de" -import { dict as uiEs } from "@opencode-ai/ui/i18n/es" -import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" -import { dict as uiDa } from "@opencode-ai/ui/i18n/da" -import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" -import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" -import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" -import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" -import { dict as uiNo } from "@opencode-ai/ui/i18n/no" -import { dict as uiBr } from "@opencode-ai/ui/i18n/br" -import { dict as uiTh } from "@opencode-ai/ui/i18n/th" -import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" -import { dict as uiNl } from "@opencode-ai/ui/i18n/nl" -import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" // kilocode_change start import { dict as kiloEn } from "@kilocode/kilo-i18n/en" -import { dict as kiloZh } from "@kilocode/kilo-i18n/zh" -import { dict as kiloZht } from "@kilocode/kilo-i18n/zht" -import { dict as kiloKo } from "@kilocode/kilo-i18n/ko" -import { dict as kiloDe } from "@kilocode/kilo-i18n/de" -import { dict as kiloEs } from "@kilocode/kilo-i18n/es" -import { dict as kiloFr } from "@kilocode/kilo-i18n/fr" -import { dict as kiloDa } from "@kilocode/kilo-i18n/da" -import { dict as kiloJa } from "@kilocode/kilo-i18n/ja" -import { dict as kiloPl } from "@kilocode/kilo-i18n/pl" -import { dict as kiloRu } from "@kilocode/kilo-i18n/ru" -import { dict as kiloAr } from "@kilocode/kilo-i18n/ar" -import { dict as kiloNo } from "@kilocode/kilo-i18n/no" -import { dict as kiloBr } from "@kilocode/kilo-i18n/br" -import { dict as kiloTh } from "@kilocode/kilo-i18n/th" -import { dict as kiloBs } from "@kilocode/kilo-i18n/bs" -import { dict as kiloNl } from "@kilocode/kilo-i18n/nl" -import { dict as kiloTr } from "@kilocode/kilo-i18n/tr" // kilocode_change end export type Locale = @@ -83,6 +32,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn & typeof kiloEn // kilocode_change type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -152,26 +102,44 @@ const LABEL_KEY: Record = { tr: "language.tr", } -const base = i18n.flatten({ ...en, ...uiEn, ...kiloEn }) -const DICT: Record = { - en: base, - zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh, ...kiloZh }) }, - zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht, ...kiloZht }) }, - ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo, ...kiloKo }) }, - de: { ...base, ...i18n.flatten({ ...de, ...uiDe, ...kiloDe }) }, - es: { ...base, ...i18n.flatten({ ...es, ...uiEs, ...kiloEs }) }, - fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr, ...kiloFr }) }, - da: { ...base, ...i18n.flatten({ ...da, ...uiDa, ...kiloDa }) }, - ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa, ...kiloJa }) }, - pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl, ...kiloPl }) }, - ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu, ...kiloRu }) }, - ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr, ...kiloAr }) }, - no: { ...base, ...i18n.flatten({ ...no, ...uiNo, ...kiloNo }) }, - br: { ...base, ...i18n.flatten({ ...br, ...uiBr, ...kiloBr }) }, - th: { ...base, ...i18n.flatten({ ...th, ...uiTh, ...kiloTh }) }, - bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs, ...kiloBs }) }, - nl: { ...base, ...i18n.flatten({ ...nl, ...uiNl, ...kiloNl }) }, - tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr, ...kiloTr }) }, +const base = i18n.flatten({ ...en, ...uiEn, ...kiloEn }) as Dictionary // kilocode_change +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -198,28 +166,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] -type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" -const PARITY_CHECK: Record, Record> = { - zh, - zht, - ko, - de, - es, - fr, - da, - ja, - pl, - ru, - ar, - no, - br, - th, - bs, - nl, - tr, -} -void PARITY_CHECK - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -234,27 +180,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +if (warm !== "en") void loadDict(warm) + export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 5062a3bfa2..ee06e394f1 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@kilocode/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 1c0c879a42..d4eb6e6e64 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,6 +14,8 @@ import { useSDK } from "./sdk" import type { Message, Part } from "@kilocode/sdk/v2/client" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + function sortParts(parts: Part[]) { return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) } @@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ batch(() => { input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) for (const p of next.part) { - input.setStore("part", p.id, p.part) + const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type)) + if (filtered.length) input.setStore("part", p.id, filtered) } setMeta("limit", key, message.length) setMeta("cursor", key, next.cursor) @@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -import { dict as ar } from "@/i18n/ar" -import { dict as br } from "@/i18n/br" -import { dict as bs } from "@/i18n/bs" -import { dict as da } from "@/i18n/da" -import { dict as de } from "@/i18n/de" -import { dict as en } from "@/i18n/en" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as ja } from "@/i18n/ja" -import { dict as ko } from "@/i18n/ko" -import { dict as no } from "@/i18n/no" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as th } from "@/i18n/th" -import { dict as tr } from "@/i18n/tr" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" +const template = "Terminal {{number}}" -const numbered = Array.from( - new Set([ - en["terminal.title.numbered"], - ar["terminal.title.numbered"], - br["terminal.title.numbered"], - bs["terminal.title.numbered"], - da["terminal.title.numbered"], - de["terminal.title.numbered"], - es["terminal.title.numbered"], - fr["terminal.title.numbered"], - ja["terminal.title.numbered"], - ko["terminal.title.numbered"], - no["terminal.title.numbered"], - pl["terminal.title.numbered"], - ru["terminal.title.numbered"], - th["terminal.title.numbered"], - tr["terminal.title.numbered"], - zh["terminal.title.numbered"], - zht["terminal.title.numbered"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 9427f9e660..da22c55523 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,23 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } -// kilocode_change start -// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements). -// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows. -const originalGetComputedStyle = window.getComputedStyle -window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { - if (!(elt instanceof Element)) { - // Fall back to a safe element when a non-element is passed. - return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) - } - return originalGetComputedStyle(elt, pseudoElt ?? undefined) -}) as typeof window.getComputedStyle -// kilocode_change end +const localUrl = () => + `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + +const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_KILO_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_KILO_SERVER_PORT ?? "4096"}` // kilocode_change + if (location.hostname.includes("opencode.ai")) return localUrl() + if (import.meta.env.DEV) return localUrl() + if (isLocalHost()) return localUrl() return location.origin } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 556af20b23..460553edce 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -15,7 +15,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b676435d69..69ad646738 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -276,7 +276,7 @@ export const dict = { "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeFile": "Remove file from context", - "prompt.action.attachFile": "Add file", + "prompt.action.attachFile": "Add files", "prompt.attachment.remove": "Remove attachment", "prompt.action.send": "Send", "prompt.action.stop": "Stop", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 53063f48f8..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" +export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + return ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() { + + + {language.t("common.loading")} + + {language.t("command.project.open")} + + + diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 17780a092e..465e9a083d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined + let dialogRun = 0 + let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -211,13 +210,22 @@ export default function Layout(props: ParentProps) { onMount(() => { const stop = () => setState("sizing", false) + const blur = () => reset() + const hide = () => { + if (document.visibilityState !== "hidden") return + reset() + } window.addEventListener("pointerup", stop) window.addEventListener("pointercancel", stop) window.addEventListener("blur", stop) + window.addEventListener("blur", blur) + document.addEventListener("visibilitychange", hide) onCleanup(() => { window.removeEventListener("pointerup", stop) window.removeEventListener("pointercancel", stop) window.removeEventListener("blur", stop) + window.removeEventListener("blur", blur) + document.removeEventListener("visibilitychange", hide) }) }) @@ -237,6 +245,12 @@ export default function Layout(props: ParentProps) { navLeave.current = undefined } + const reset = () => { + disarm() + setState("hoverSession", undefined) + setHoverProject(undefined) + } + const arm = () => { if (layout.sidebar.opened()) return if (state.hoverProject === undefined) return @@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) { const clearSidebarHoverState = () => { if (layout.sidebar.opened()) return - setState("hoverSession", undefined) - setHoverProject(undefined) + reset() } const navigateWithSidebarReset = (href: string) => { @@ -322,10 +335,9 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) - const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -1138,10 +1150,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1192,15 +1204,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1427,7 +1451,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1448,10 +1478,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } @@ -1784,6 +1818,9 @@ export default function Layout(props: ParentProps) { document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) }) + const side = createMemo(() => Math.max(layout.sidebar.width(), 244)) + const panel = createMemo(() => Math.max(side() - 64, 0)) + const loadedSessionDirs = new Set() createEffect( @@ -1975,6 +2012,10 @@ export default function Layout(props: ParentProps) { onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectFocus: (worktree) => aim.activate(worktree), + onHoverOpenChanged: (worktree, hoverOpen) => { + if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return + setState("hoverProject", hoverOpen ? worktree : undefined) + }, navigateToProject, openSidebar: () => layout.sidebar.open(), closeProject, @@ -2056,7 +2097,7 @@ export default function Layout(props: ParentProps) { "max-w-full overflow-hidden": panelProps.mobile, }} style={{ - width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, + width: panelProps.mobile ? undefined : `${panel()}px`, }} > @@ -2346,7 +2389,7 @@ export default function Layout(props: ParentProps) { "absolute inset-y-0 left-0": true, "z-10": true, }} - style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} + style={{ width: `${side()}px` }} ref={(el) => { setState("nav", el) }} @@ -2361,24 +2404,29 @@ export default function Layout(props: ParentProps) { }} > {sidebarContent()} - - setState("sizing", true)}> - { - setState("sizing", true) - if (sizet !== undefined) clearTimeout(sizet) - sizet = window.setTimeout(() => setState("sizing", false), 120) - layout.sidebar.resize(w) - }} - /> - - + + setState("sizing", true)} + > + { + setState("sizing", true) + if (sizet !== undefined) clearTimeout(sizet) + sizet = window.setTimeout(() => setState("sizing", false), 120) + layout.sidebar.resize(w) + }} + /> + + + diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 4df65c2e6b..b48d9daa74 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -104,7 +104,7 @@ const SessionRow = (props: { }): JSX.Element => ( - - - }> - - - - - - - - - - 0}> - - - - - - {props.session.title} - + + }> + + + + + + + + + + 0}> + + + + {props.session.title} ) @@ -157,34 +153,49 @@ const SessionHoverPreview = (props: { messageLabel: (message: Message) => string | undefined onMessageSelect: (message: Message) => void trigger: JSX.Element -}): JSX.Element => ( - props.setHoverSession(open ? props.session.id : undefined)} - > - {props.language.t("session.messages.loading")}} +}): JSX.Element => { + let ref: HTMLDivElement | undefined + + return ( + + {props.trigger} + + } + open={props.hoverSession() === props.session.id} + onOpenChange={(open) => { + if (!open) { + props.setHoverSession(undefined) + return + } + if (!ref?.matches(":hover")) return + props.setHoverSession(props.session.id) + }} > - - - - - -) + {props.language.t("session.messages.loading")}} + > + + + + + + ) +} export const SessionItem = (props: SessionItemProps): JSX.Element => { const params = useParams() @@ -298,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return ( - - {item} - - } - > - { - if (!isActive()) - layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) + + + + {item} + + } + > + { + if (!isActive()) + layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) - navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + }} + trigger={item} + /> + + + + - - - - - { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.session) - }} - /> - + > + + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + + ) @@ -375,30 +395,26 @@ export const NewSessionItem = (props: { { props.setHoverSession(undefined) if (layout.sidebar.opened()) return props.clearHoverProjectSoon() }} > - - - - - - {label} - + + + {label} ) return ( - + + {item} } diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 2528264561..aff0645dd8 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -23,6 +23,7 @@ export type ProjectSidebarContext = { onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseLeave: (worktree: string) => void onProjectFocus: (worktree: string) => void + onHoverOpenChanged: (worktree: string, hovered: boolean) => void navigateToProject: (directory: string) => void openSidebar: () => void closeProject: (directory: string) => void @@ -109,8 +110,14 @@ const ProjectTile = (props: { "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), }} onPointerDown={(event) => { + if (event.button === 0 && !event.ctrlKey) { + props.setOpen(false) + props.setSuppressHover(true) + return + } if (!props.overlay()) return if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return + props.setOpen(false) props.setSuppressHover(true) event.preventDefault() }} @@ -130,12 +137,11 @@ const ProjectTile = (props: { props.onProjectFocus(props.project.worktree) }} onClick={() => { + props.setOpen(false) if (props.selected()) { - props.setSuppressHover(true) layout.sidebar.toggle() return } - props.setSuppressHover(false) props.navigateToProject(props.project.worktree) }} onBlur={() => props.setOpen(false)} @@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: { projectChildren: Accessor> workspaceSessions: (directory: string) => ReturnType workspaceChildren: (directory: string) => Map - setOpen: (value: boolean) => void ctx: ProjectSidebarContext language: ReturnType }): JSX.Element => ( @@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: { class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" onClick={() => { props.ctx.openSidebar() - props.setOpen(false) + props.ctx.onHoverOpenChanged(props.project.worktree, false) if (props.selected()) return props.ctx.navigateToProject(props.project.worktree) }} @@ -284,28 +289,16 @@ export const SortableProject = (props: { const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const [state, setState] = createStore({ - open: false, menu: false, suppressHover: false, }) + const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) - const active = createMemo( - () => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree), - ) + const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject())) - createEffect(() => { - if (preview()) return - if (!state.open) return - setState("open", false) - }) - - createEffect(() => { - if (!selected()) return - if (!state.open) return - setState("open", false) - }) + const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu const label = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) @@ -346,7 +339,7 @@ export const SortableProject = (props: { workspacesEnabled={props.ctx.workspacesEnabled} closeProject={props.ctx.closeProject} setMenu={(value) => setState("menu", value)} - setOpen={(value) => setState("open", value)} + setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)} setSuppressHover={(value) => setState("suppressHover", value)} language={language} /> @@ -357,7 +350,7 @@ export const SortableProject = (props: { { if (state.menu) return if (value && state.suppressHover) return - setState("open", value) + props.ctx.onHoverOpenChanged(props.project.worktree, value) if (value) props.ctx.setHoverSession(undefined) }} > @@ -381,7 +374,6 @@ export const SortableProject = (props: { projectChildren={projectChildren} workspaceSessions={workspaceSessions} workspaceChildren={workspaceChildren} - setOpen={(value) => setState("open", value)} ctx={props.ctx} language={language} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 513c522e97..0cbe4d8b46 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -42,7 +42,13 @@ import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" -import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" +import { + createOpenReviewFile, + createSessionTabs, + createSizing, + focusTerminalById, + shouldFocusTerminalOnKeyDown, +} from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" @@ -241,14 +247,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (added <= 0) return if (growth <= 0) return + + if (opts?.prefetch) { + const current = turnStart() + preserveScroll(() => setTurnStart(current + growth)) + return + } + if (turnStart() !== start) return - const reveal = !opts?.prefetch const currentRendered = renderedUserMessages().length const base = Math.max(beforeRendered, currentRendered) - const target = reveal ? Math.min(afterVisible, base + turnBatch) : base - const nextStart = Math.max(0, afterVisible - target) - preserveScroll(() => setTurnStart(nextStart)) + const target = Math.min(afterVisible, base + turnBatch) + preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target))) } const onScrollerScroll = () => { @@ -950,7 +961,7 @@ export default function Page() { // Prefer the open terminal over the composer when it can take focus if (view().terminal.opened()) { const id = terminal.active() - if (id && focusTerminalById(id)) return + if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return } // Only treat explicit scroll keys as potential "user scroll" gestures. @@ -1273,8 +1284,6 @@ export default function Page() { on( () => sdk.directory, () => { - void file.tree.list("") - const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1729,6 +1738,9 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, + historyMore, + historyLoading, + loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1800,7 +1812,7 @@ export default function Page() { - + { @@ -86,6 +87,26 @@ describe("focusTerminalById", () => { }) }) +describe("shouldFocusTerminalOnKeyDown", () => { + test("skips pure modifier keys", () => { + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false) + }) + + test("skips shortcut key combos", () => { + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false) + }) + + test("keeps plain typing focused on terminal", () => { + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true) + }) +}) + describe("getTabReorderIndex", () => { test("returns target index for valid drag reorder", () => { expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c3571f3ffc..7e2c1ccf7b 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => { return true } +const skip = new Set(["Alt", "Control", "Meta", "Shift"]) + +export const shouldFocusTerminalOnKeyDown = (event: Pick) => { + if (skip.has(event.key)) return false + return !(event.ctrlKey || event.metaKey || event.altKey) +} + export const createOpenReviewFile = (input: { showAllFiles: () => void tabForPath: (path: string) => string diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 401e3cd812..e6c3d0c1d4 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,4 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" @@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { messageAgentColor } from "@/utils/agent" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import { makeTimer } from "@solid-primitives/timer" type MessageComment = { path: string @@ -250,38 +251,21 @@ export function MessageTimeline(props: { const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) - const [slot, setSlot] = createStore({ - open: false, - show: false, - fade: false, + const [timeoutDone, setTimeoutDone] = createSignal(true) + + const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => { + if (working()) return "showing" + if (prev === "showing" || !timeoutDone()) return "hiding" + return "hidden" }) - let f: number | undefined - const clear = () => { - if (f !== undefined) window.clearTimeout(f) - f = undefined - } + createEffect(() => { + if (workingStatus() !== "hiding") return + + setTimeoutDone(false) + makeTimer(() => setTimeoutDone(true), 260, setTimeout) + }) - onCleanup(clear) - createEffect( - on( - working, - (on, prev) => { - clear() - if (on) { - setSlot({ open: true, show: true, fade: false }) - return - } - if (prev) { - setSlot({ open: false, show: true, fade: true }) - f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) - return - } - setSlot({ open: false, show: false, fade: false }) - }, - { defer: true }, - ), - ) const activeMessageID = createMemo(() => { const parentID = pending()?.parentID if (parentID) { @@ -676,17 +660,15 @@ export function MessageTimeline(props: { - + @@ -912,7 +894,6 @@ export function MessageTimeline(props: { - { const active = createMemo(() => activeMessageID() === messageID) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + equals: (a, b) => + a.length === b.length && + a.every( + (c, i) => + c.path === b[i].path && + c.comment === b[i].comment && + c.selection?.startLine === b[i].selection?.startLine && + c.selection?.endLine === b[i].selection?.endLine, + ), }) const commentCount = createMemo(() => comments().length) return ( @@ -998,6 +987,7 @@ export function MessageTimeline(props: { { props.size.touch() layout.fileTree.resize(width) }} - onCollapse={layout.fileTree.close} /> diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index cdb07f84ac..aecc33f9ff 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), - keybind: "mod+p", + keybind: "mod+k,mod+p", slash: "open", onSelect: () => dialog.show(() => ), }), diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index fe13be6bda..474e2ad321 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: { sessionID: () => string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts new file mode 100644 index 0000000000..04768dde05 --- /dev/null +++ b/packages/app/src/utils/prompt.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import type { Part } from "@kilocode/sdk/v2" +import { extractPromptFromParts } from "./prompt" + +describe("extractPromptFromParts", () => { + test("restores multiple uploaded attachments", () => { + const parts = [ + { + id: "text_1", + type: "text", + text: "check these", + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "file_1", + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAA", + filename: "a.png", + sessionID: "ses_1", + messageID: "msg_1", + }, + { + id: "file_2", + type: "file", + mime: "application/pdf", + url: "data:application/pdf;base64,BBB", + filename: "b.pdf", + sessionID: "ses_1", + messageID: "msg_1", + }, + ] satisfies Part[] + + const result = extractPromptFromParts(parts) + + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ type: "text", content: "check these" }) + expect(result.slice(1)).toMatchObject([ + { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" }, + ]) + }) +}) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 45a323c7be..a13fd34ef7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -14,6 +14,15 @@ interface CheckServerHealthOptions { const defaultTimeoutMs = 3000 const defaultRetryCount = 2 const defaultRetryDelayMs = 100 +const cacheMs = 750 +const healthCache = new Map< + string, + { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } +>() + +function cacheKey(server: ServerConnection.HttpBase) { + return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` +} function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout @@ -87,5 +96,18 @@ export function useCheckServerHealth() { const platform = usePlatform() const fetcher = platform.fetch ?? globalThis.fetch - return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) + return (http: ServerConnection.HttpBase) => { + const key = cacheKey(http) + const hit = healthCache.get(key) + const now = Date.now() + if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise + const promise = checkServerHealth(http, fetcher).finally(() => { + const next = healthCache.get(key) + if (!next || next.promise !== promise) return + next.done = true + next.at = Date.now() + }) + healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) + return promise + } } diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 6dea812ec8..78e5a0c565 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -1,106 +1,89 @@ -import alert01 from "@opencode-ai/ui/audio/alert-01.aac" -import alert02 from "@opencode-ai/ui/audio/alert-02.aac" -import alert03 from "@opencode-ai/ui/audio/alert-03.aac" -import alert04 from "@opencode-ai/ui/audio/alert-04.aac" -import alert05 from "@opencode-ai/ui/audio/alert-05.aac" -import alert06 from "@opencode-ai/ui/audio/alert-06.aac" -import alert07 from "@opencode-ai/ui/audio/alert-07.aac" -import alert08 from "@opencode-ai/ui/audio/alert-08.aac" -import alert09 from "@opencode-ai/ui/audio/alert-09.aac" -import alert10 from "@opencode-ai/ui/audio/alert-10.aac" -import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" -import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" -import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" -import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" -import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" -import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" -import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" -import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" -import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" -import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" -import nope01 from "@opencode-ai/ui/audio/nope-01.aac" -import nope02 from "@opencode-ai/ui/audio/nope-02.aac" -import nope03 from "@opencode-ai/ui/audio/nope-03.aac" -import nope04 from "@opencode-ai/ui/audio/nope-04.aac" -import nope05 from "@opencode-ai/ui/audio/nope-05.aac" -import nope06 from "@opencode-ai/ui/audio/nope-06.aac" -import nope07 from "@opencode-ai/ui/audio/nope-07.aac" -import nope08 from "@opencode-ai/ui/audio/nope-08.aac" -import nope09 from "@opencode-ai/ui/audio/nope-09.aac" -import nope10 from "@opencode-ai/ui/audio/nope-10.aac" -import nope11 from "@opencode-ai/ui/audio/nope-11.aac" -import nope12 from "@opencode-ai/ui/audio/nope-12.aac" -import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" -import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" -import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" -import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" -import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" -import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" -import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" -import yup01 from "@opencode-ai/ui/audio/yup-01.aac" -import yup02 from "@opencode-ai/ui/audio/yup-02.aac" -import yup03 from "@opencode-ai/ui/audio/yup-03.aac" -import yup04 from "@opencode-ai/ui/audio/yup-04.aac" -import yup05 from "@opencode-ai/ui/audio/yup-05.aac" -import yup06 from "@opencode-ai/ui/audio/yup-06.aac" +let files: Record Promise> | undefined +let loads: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< + string, + () => Promise + > + return files +} export const SOUND_OPTIONS = [ - { id: "alert-01", label: "sound.option.alert01", src: alert01 }, - { id: "alert-02", label: "sound.option.alert02", src: alert02 }, - { id: "alert-03", label: "sound.option.alert03", src: alert03 }, - { id: "alert-04", label: "sound.option.alert04", src: alert04 }, - { id: "alert-05", label: "sound.option.alert05", src: alert05 }, - { id: "alert-06", label: "sound.option.alert06", src: alert06 }, - { id: "alert-07", label: "sound.option.alert07", src: alert07 }, - { id: "alert-08", label: "sound.option.alert08", src: alert08 }, - { id: "alert-09", label: "sound.option.alert09", src: alert09 }, - { id: "alert-10", label: "sound.option.alert10", src: alert10 }, - { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, - { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, - { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, - { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, - { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, - { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, - { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, - { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, - { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, - { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, - { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, - { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, - { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, - { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, - { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, - { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, - { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, - { id: "nope-01", label: "sound.option.nope01", src: nope01 }, - { id: "nope-02", label: "sound.option.nope02", src: nope02 }, - { id: "nope-03", label: "sound.option.nope03", src: nope03 }, - { id: "nope-04", label: "sound.option.nope04", src: nope04 }, - { id: "nope-05", label: "sound.option.nope05", src: nope05 }, - { id: "nope-06", label: "sound.option.nope06", src: nope06 }, - { id: "nope-07", label: "sound.option.nope07", src: nope07 }, - { id: "nope-08", label: "sound.option.nope08", src: nope08 }, - { id: "nope-09", label: "sound.option.nope09", src: nope09 }, - { id: "nope-10", label: "sound.option.nope10", src: nope10 }, - { id: "nope-11", label: "sound.option.nope11", src: nope11 }, - { id: "nope-12", label: "sound.option.nope12", src: nope12 }, - { id: "yup-01", label: "sound.option.yup01", src: yup01 }, - { id: "yup-02", label: "sound.option.yup02", src: yup02 }, - { id: "yup-03", label: "sound.option.yup03", src: yup03 }, - { id: "yup-04", label: "sound.option.yup04", src: yup04 }, - { id: "yup-05", label: "sound.option.yup05", src: yup05 }, - { id: "yup-06", label: "sound.option.yup06", src: yup06 }, + { id: "alert-01", label: "sound.option.alert01" }, + { id: "alert-02", label: "sound.option.alert02" }, + { id: "alert-03", label: "sound.option.alert03" }, + { id: "alert-04", label: "sound.option.alert04" }, + { id: "alert-05", label: "sound.option.alert05" }, + { id: "alert-06", label: "sound.option.alert06" }, + { id: "alert-07", label: "sound.option.alert07" }, + { id: "alert-08", label: "sound.option.alert08" }, + { id: "alert-09", label: "sound.option.alert09" }, + { id: "alert-10", label: "sound.option.alert10" }, + { id: "bip-bop-01", label: "sound.option.bipbop01" }, + { id: "bip-bop-02", label: "sound.option.bipbop02" }, + { id: "bip-bop-03", label: "sound.option.bipbop03" }, + { id: "bip-bop-04", label: "sound.option.bipbop04" }, + { id: "bip-bop-05", label: "sound.option.bipbop05" }, + { id: "bip-bop-06", label: "sound.option.bipbop06" }, + { id: "bip-bop-07", label: "sound.option.bipbop07" }, + { id: "bip-bop-08", label: "sound.option.bipbop08" }, + { id: "bip-bop-09", label: "sound.option.bipbop09" }, + { id: "bip-bop-10", label: "sound.option.bipbop10" }, + { id: "staplebops-01", label: "sound.option.staplebops01" }, + { id: "staplebops-02", label: "sound.option.staplebops02" }, + { id: "staplebops-03", label: "sound.option.staplebops03" }, + { id: "staplebops-04", label: "sound.option.staplebops04" }, + { id: "staplebops-05", label: "sound.option.staplebops05" }, + { id: "staplebops-06", label: "sound.option.staplebops06" }, + { id: "staplebops-07", label: "sound.option.staplebops07" }, + { id: "nope-01", label: "sound.option.nope01" }, + { id: "nope-02", label: "sound.option.nope02" }, + { id: "nope-03", label: "sound.option.nope03" }, + { id: "nope-04", label: "sound.option.nope04" }, + { id: "nope-05", label: "sound.option.nope05" }, + { id: "nope-06", label: "sound.option.nope06" }, + { id: "nope-07", label: "sound.option.nope07" }, + { id: "nope-08", label: "sound.option.nope08" }, + { id: "nope-09", label: "sound.option.nope09" }, + { id: "nope-10", label: "sound.option.nope10" }, + { id: "nope-11", label: "sound.option.nope11" }, + { id: "nope-12", label: "sound.option.nope12" }, + { id: "yup-01", label: "sound.option.yup01" }, + { id: "yup-02", label: "sound.option.yup02" }, + { id: "yup-03", label: "sound.option.yup03" }, + { id: "yup-04", label: "sound.option.yup04" }, + { id: "yup-05", label: "sound.option.yup05" }, + { id: "yup-06", label: "sound.option.yup06" }, ] as const export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundID = SoundOption["id"] -const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record +function getLoads() { + if (loads) return loads + loads = Object.fromEntries( + Object.entries(getFiles()).flatMap(([path, load]) => { + const file = path.split("/").at(-1) + if (!file) return [] + return [[file.replace(/\.aac$/, ""), load] as const] + }), + ) as Record Promise> + return loads +} + +const cache = new Map>() export function soundSrc(id: string | undefined) { - if (!id) return - if (!(id in soundById)) return - return soundById[id as SoundID] + const loads = getLoads() + if (!id || !(id in loads)) return Promise.resolve(undefined) + const key = id as SoundID + const hit = cache.get(key) + if (hit) return hit + const next = loads[key]().catch(() => undefined) + cache.set(key, next) + return next } export function playSound(src: string | undefined) { @@ -108,10 +91,12 @@ export function playSound(src: string | undefined) { if (!src) return const audio = new Audio(src) audio.play().catch(() => undefined) - - // Return a cleanup function to pause the sound. return () => { audio.pause() audio.currentTime = 0 } } + +export function playSoundById(id: string | undefined) { + return soundSrc(id).then((src) => playSound(src)) +} diff --git a/packages/app/vite.js b/packages/app/vite.js index 671bc53cbe..bdbf32c091 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) + /** * @type {import("vite").PluginOption} */ @@ -21,6 +24,15 @@ export default [ } }, }, + { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + return html.replace( + '', + ``, + ) + }, + }, tailwindcss(), solidPlugin(), ] diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index a11df8c5a2..045ff7512c 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.5 +ARG BUN_VERSION=1.3.11 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts index fba301f36c..f2d918bd21 100644 --- a/packages/desktop-electron/src/main/cli.ts +++ b/packages/desktop-electron/src/main/cli.ts @@ -35,6 +35,7 @@ export type CommandEvent = export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } export type CommandChild = { + pid: number | undefined kill: () => void } @@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record) { treeKill(child.pid) } - return { events, child: { kill }, exit } + return { events, child: { pid: child.pid, kill }, exit } } function handleSqliteProgress(events: EventEmitter, line: string) { diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index da2b36abf3..286779cba2 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -81,6 +81,17 @@ function setupApp() { killSidecar() }) + app.on("will-quit", () => { + killSidecar() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + killSidecar() + app.exit(0) + }) + } + void app.whenReady().then(async () => { // migrate() app.setAsDefaultProtocolClient("opencode") @@ -234,8 +245,15 @@ registerIpcHandlers({ function killSidecar() { if (!sidecar) return + const pid = sidecar.pid sidecar.kill() sidecar = null + // tree-kill is async; also send process group signal as immediate fallback + if (pid && process.platform !== "win32") { + try { + process.kill(-pid, "SIGTERM") + } catch {} + } } function ensureLoopbackNoProxy() { diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index ec2b4d1e7a..44f2e6360c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -246,6 +249,17 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } const [windowCount] = createResource(() => window.api.getWindowCount()) @@ -257,6 +271,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) const servers = () => { const data = sidecar() @@ -309,15 +324,14 @@ render(() => { return ( - - + + {(_) => { return ( 1} > diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 3f21a7186e..33af57e1d7 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -414,6 +417,17 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) @@ -423,6 +437,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive const servers = () => { @@ -465,8 +480,8 @@ render(() => { return ( - - + + {(_) => { return ( >>>>>> catrielmuller/opencode-v1.3.1 import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" +import { Effect, ServiceMap, Layer } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change @@ -59,9 +65,21 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() + export interface Interface { + readonly get: (agent: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly defaultAgent: () => Effect.Effect + readonly generate: (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) => Effect.Effect<{ + identifier: string + whenToUse: string + systemPrompt: string + }> + } +<<<<<<< HEAD const skillDirs = await Skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] // kilocode_change start — safe bash commands that don't need user approval. @@ -370,10 +388,32 @@ export namespace Agent { codesearch: "allow", codebase_search: "allow", // kilocode_change read: "allow", +======= + type State = Omit + + export class Service extends ServiceMap.Service()("@opencode/Agent") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = () => Effect.promise(() => Config.get()) + const auth = yield* Auth.Service + + const state = yield* InstanceState.make( + Effect.fn("Agent.state")(function* (ctx) { + const cfg = yield* config() + const skillDirs = yield* Effect.promise(() => Skill.dirs()) + const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] + + const defaults = Permission.fromConfig({ + "*": "allow", + doom_loop: "ask", +>>>>>>> catrielmuller/opencode-v1.3.1 external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, +<<<<<<< HEAD }), user, ), @@ -472,27 +512,324 @@ export namespace Agent { // kilocode_change end item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } +======= + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + // mirrors github.com/github/gitignore Node.gitignore pattern for .env files + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) - // Ensure Truncate.GLOB is allowed unless explicitly configured - for (const name in result) { - const agent = result[name] - const explicit = agent.permission.some((r) => { - if (r.permission !== "external_directory") return false - if (r.action !== "deny") return false - return r.pattern === Truncate.GLOB - }) - if (explicit) continue + const user = Permission.fromConfig(cfg.permission ?? {}) +>>>>>>> catrielmuller/opencode-v1.3.1 + const agents: Record = { + build: { + name: "build", + description: "The default agent. Executes tools based on configured permissions.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_enter: "allow", + }), + user, + ), + mode: "primary", + native: true, + }, + plan: { + name: "plan", + description: "Plan mode. Disallows all edit tools.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_exit: "allow", + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: + "allow", + }, + }), + user, + ), + mode: "primary", + native: true, + }, + general: { + name: "general", + description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + todoread: "deny", + todowrite: "deny", + }), + user, + ), + options: {}, + mode: "subagent", + native: true, + }, + explore: { + name: "explore", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + }), + user, + ), + description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, + prompt: PROMPT_EXPLORE, + options: {}, + mode: "subagent", + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + options: {}, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0.5, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_TITLE, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_SUMMARY, + }, + } + +<<<<<<< HEAD result[name].permission = Permission.merge( result[name].permission, Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), - ) - } +======= + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete agents[key] + continue + } + let item = agents[key] + if (!item) + item = agents[key] = { + name: key, + mode: "all", + permission: Permission.merge(defaults, user), + options: {}, + native: false, + } + if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.hidden = value.hidden ?? item.hidden + item.name = value.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) + } - return result - }) + // Ensure Truncate.GLOB is allowed unless explicitly configured + for (const name in agents) { + const agent = agents[name] + const explicit = agent.permission.some((r) => { + if (r.permission !== "external_directory") return false + if (r.action !== "deny") return false + return r.pattern === Truncate.GLOB + }) + if (explicit) continue + + agents[name].permission = Permission.merge( + agents[name].permission, + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + ) + } + + const get = Effect.fnUntraced(function* (agent: string) { + return agents[agent] + }) + + const list = Effect.fnUntraced(function* () { + const cfg = yield* config() + return pipe( + agents, + values(), + sortBy( + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], + [(x) => x.name, "asc"], + ), + ) + }) + + const defaultAgent = Effect.fnUntraced(function* () { + const c = yield* config() + if (c.default_agent) { + const agent = agents[c.default_agent] + if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) + if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) + if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) + return agent.name + } + const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + if (!visible) throw new Error("no primary visible agent found") + return visible.name + }) + + return { + get, + list, + defaultAgent, + } satisfies State + }), +>>>>>>> catrielmuller/opencode-v1.3.1 + ) + + return Service.of({ + get: Effect.fn("Agent.get")(function* (agent: string) { + return yield* InstanceState.useEffect(state, (s) => s.get(agent)) + }), + list: Effect.fn("Agent.list")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.list()) + }), + defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) + }), + generate: Effect.fn("Agent.generate")(function* (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) { + const cfg = yield* config() + const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel())) + const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID)) + const language = yield* Effect.promise(() => Provider.getLanguage(resolved)) + + const system = [PROMPT_GENERATE] + yield* Effect.promise(() => + Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }), + ) + const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + + const params = { + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + metadata: { + userId: cfg.username ?? "unknown", + }, + }, + temperature: 0.3, + messages: [ + ...system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + ), + { + role: "user", + content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, + }, + ], + model: language, + schema: z.object({ + identifier: z.string(), + whenToUse: z.string(), + systemPrompt: z.string(), + }), + } satisfies Parameters[0] + + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + if (model.providerID === "openai" && authInfo?.type === "oauth") { + return yield* Effect.promise(async () => { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(resolved, { + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return result.object + }) + } + + return yield* Effect.promise(() => generateObject(params).then((r) => r.object)) + }), + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) export async function get(agent: string) { +<<<<<<< HEAD // kilocode_change start - Treat "build" as "code" for backward compatibility const effectiveAgent = agent === "build" ? "code" : agent return state().then((x) => x[effectiveAgent]) @@ -591,6 +928,21 @@ export namespace Agent { const result = await generateObject(params) return result.object +======= + return runPromise((svc) => svc.get(agent)) + } + + export async function list() { + return runPromise((svc) => svc.list()) + } + + export async function defaultAgent() { + return runPromise((svc) => svc.defaultAgent()) + } + + export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { + return runPromise((svc) => svc.generate(input)) +>>>>>>> catrielmuller/opencode-v1.3.1 } // kilocode_change start diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index fe8747bce7..c09294eddd 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -10,6 +10,26 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => const println = (msg: string) => Effect.sync(() => UI.println(msg)) +const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL + +const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "") + +export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) => + `${account.email} ${dim(account.url)}${activeSuffix(isActive)}` + +const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) => + `${org.name} (${account.email})${activeSuffix(isActive)}` + +export const formatOrgLine = ( + account: { email: string; url: string }, + org: { id: string; name: string }, + isActive: boolean, +) => { + const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " + const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name + return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}` +} + const isActiveOrgChoice = ( active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>, choice: { accountID: AccountID; orgID: OrgID }, @@ -76,10 +96,9 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) { const opts = accounts.map((a) => { const isActive = Option.isSome(activeID) && activeID.value === a.id - const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL return { value: a, - label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`, + label: formatAccountLabel(a, isActive), } }) @@ -109,9 +128,7 @@ const switchEffect = Effect.fn("switch")(function* () { const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) return { value: { orgID: org.id, accountID: group.account.id, label: org.name }, - label: isActive - ? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)" - : `${org.name} (${group.account.email})`, + label: formatOrgChoiceLabel(group.account, org, isActive), } }), ) @@ -139,15 +156,21 @@ const orgsEffect = Effect.fn("orgs")(function* () { for (const group of groups) { for (const org of group.orgs) { const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) - const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " - const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name - const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL - const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL - yield* println(` ${dot} ${name} ${email} ${id}`) + yield* println(formatOrgLine(group.account, org, isActive)) } } }) +const openEffect = Effect.fn("open")(function* () { + const service = yield* Account.Service + const active = yield* service.active() + if (Option.isNone(active)) return yield* println("No active account") + + const url = active.value.url + yield* openBrowser(url) + yield* Prompt.outro("Opened " + url) +}) + export const LoginCommand = cmd({ command: "login ", describe: false, @@ -195,6 +218,15 @@ export const OrgsCommand = cmd({ }, }) +export const OpenCommand = cmd({ + command: "open", + describe: false, + async handler() { + UI.empty() + await Account.runPromise((_svc) => openEffect()) + }, +}) + export const ConsoleCommand = cmd({ command: "console", describe: false, @@ -216,6 +248,10 @@ export const ConsoleCommand = cmd({ ...OrgsCommand, describe: "list orgs", }) + .command({ + ...OpenCommand, + describe: "open active console account", + }) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 0308aafbbb..6f2f859d18 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -116,8 +116,9 @@ export const PrCommand = cmd({ UI.println() const opencodeArgs = sessionId ? ["-s", sessionId] : [] + // kilocode_change start const opencodeProcess = Process.spawn([bin, ...opencodeArgs], { - // kilocode_change + // kilocode_change end stdin: "inherit", stdout: "inherit", stderr: "inherit", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 74547c79be..18b09ef311 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -228,7 +228,7 @@ function App() { const command = useCommandDialog() const sdk = useSDK() const toast = useToast() - const { theme, mode, setMode } = useTheme() + const { theme, mode, setMode, locked, lock, unlock } = useTheme() const sync = useSync() const exit = useExit() const promptRef = usePromptRef() @@ -595,7 +595,7 @@ function App() { category: "System", }, { - title: "Toggle appearance", + title: "Toggle Theme Mode", value: "theme.switch_mode", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") @@ -603,6 +603,16 @@ function App() { }, category: "System", }, + { + title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode", + value: "theme.mode.lock", + onSelect: (dialog) => { + if (locked()) unlock() + else lock() + dialog.clear() + }, + category: "System", + }, { title: "Help", value: "help.show", @@ -865,7 +875,7 @@ function App() { await DialogAlert.show( dialog, "Update Complete", - `Successfully updated to Kilo v${result.data.version}. Please restart the application.`, + `Successfully updated to Kilo v${result.data.version}. Please restart the application.`, // kilocode_change ) exit() diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 3ed4ae3d2c..236320cf06 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ message: store, }, ) + process.on("SIGHUP", () => exit()) return exit }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index a1f165b238..70d2c3af61 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,6 +1,6 @@ -import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" +import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" import { Glob } from "../../../../util/glob" import aura from "./theme/aura.json" with { type: "json" } @@ -294,11 +294,18 @@ function ansiToRgba(code: number): RGBA { export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light" }) => { + const renderer = useRenderer() const config = useTuiConfig() const kv = useKV() + const pick = (value: unknown) => { + if (value === "dark" || value === "light") return value + return + } + const lock = pick(kv.get("theme_mode_lock")) const [store, setStore] = createStore({ themes: DEFAULT_THEMES, - mode: kv.get("theme_mode", props.mode), + mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode, + lock, active: (config.theme ?? kv.get("theme", "kilo")) as string, // kilocode_change ready: false, }) @@ -309,7 +316,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) function init() { - resolveSystemTheme() + resolveSystemTheme(store.mode) getCustomThemes() .then((custom) => { setStore( @@ -330,14 +337,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ onMount(init) - function resolveSystemTheme() { - console.log("resolveSystemTheme") + function resolveSystemTheme(mode: "dark" | "light" = store.mode) { renderer .getPalette({ size: 16, }) .then((colors) => { - console.log(colors.palette) if (!colors.palette[0]) { if (store.active === "system") { setStore( @@ -351,7 +356,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } setStore( produce((draft) => { - draft.themes.system = generateSystem(colors, store.mode) + draft.themes.system = generateSystem(colors, mode) if (store.active === "system") { draft.ready = true } @@ -360,10 +365,34 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) } - const renderer = useRenderer() - process.on("SIGUSR2", async () => { + function apply(mode: "dark" | "light") { + kv.set("theme_mode", mode) + if (store.mode === mode) return + setStore("mode", mode) renderer.clearPaletteCache() - init() + resolveSystemTheme(mode) + } + + function pin(mode: "dark" | "light" = store.mode) { + setStore("lock", mode) + kv.set("theme_mode_lock", mode) + apply(mode) + } + + function free() { + setStore("lock", undefined) + kv.set("theme_mode_lock", undefined) + const mode = renderer.themeMode + if (mode) apply(mode) + } + + const handle = (mode: "dark" | "light") => { + if (store.lock) return + apply(mode) + } + renderer.on(CliRenderEvents.THEME_MODE, handle) + onCleanup(() => { + renderer.off(CliRenderEvents.THEME_MODE, handle) }) // kilocode_change start - safe fallback to kilo import if store lookup fails @@ -377,6 +406,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) // kilocode_change end + createEffect(() => { + renderer.setBackgroundColor(values().background) + }) + const syntax = createMemo(() => generateSyntax(values())) const subtleSyntax = createMemo(() => generateSubtleSyntax(values())) @@ -399,9 +432,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ mode() { return store.mode }, + locked() { + return store.lock !== undefined + }, + lock() { + pin(store.mode) + }, + unlock() { + free() + }, setMode(mode: "dark" | "light") { - setStore("mode", mode) - kv.set("theme_mode", mode) + pin(mode) }, set(theme: string) { setStore("active", theme) @@ -455,7 +496,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) - const transparent = RGBA.fromInts(0, 0, 0, 0) + const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0) const isDark = mode == "dark" const col = (i: number) => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 6d21b2a2c7..737c210afa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1545,6 +1545,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess streaming={true} content={props.part.text.trim()} conceal={ctx.conceal()} + fg={theme.markdownText} + bg={theme.background} /> diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 5f8acc0ca1..369efead62 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -57,7 +57,15 @@ export const UpgradeCommand = { if (err) { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { - prompts.log.error(err.stderr) // kilocode_change - removed choco special case + // kilocode_change start - removed choco special case + prompts.log.error(err.stderr) + // necessary because choco only allows install/upgrade in elevated terminals + // if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) { + // prompts.log.error("Please run the terminal as Administrator and try again") + // } else { + // prompts.log.error(err.stderr) + // } + // kilocode_change end } else if (err instanceof Error) prompts.log.error(err.message) prompts.outro("Done") return diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3e6673a1a4..d8f6be4130 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -103,6 +103,7 @@ export namespace Command { subtask: true, hints: hints(PROMPT_REVIEW), } + // kilocode_change start commands[Default.LOCAL_REVIEW] = localReviewCommand() commands[Default.LOCAL_REVIEW_UNCOMMITTED] = localReviewUncommittedCommand() diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts new file mode 100644 index 0000000000..f7b8786d08 --- /dev/null +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -0,0 +1,476 @@ +import type * as Arr from "effect/Array" +import { NodeSink, NodeStream } from "@effect/platform-node" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as PlatformError from "effect/PlatformError" +import * as Predicate from "effect/Predicate" +import type * as Scope from "effect/Scope" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" +import * as ChildProcess from "effect/unstable/process/ChildProcess" +import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" +import { + ChildProcessSpawner, + ExitCode, + make as makeSpawner, + makeHandle, + ProcessId, +} from "effect/unstable/process/ChildProcessSpawner" +import * as NodeChildProcess from "node:child_process" +import { PassThrough } from "node:stream" +import launch from "cross-spawn" + +const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) + +const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => { + switch (err.code) { + case "ENOENT": + return "NotFound" + case "EACCES": + return "PermissionDenied" + case "EEXIST": + return "AlreadyExists" + case "EISDIR": + return "BadResource" + case "ENOTDIR": + return "BadResource" + case "EBUSY": + return "Busy" + case "ELOOP": + return "BadResource" + default: + return "Unknown" + } +} + +const flatten = (command: ChildProcess.Command) => { + const commands: Array = [] + const opts: Array = [] + + const walk = (cmd: ChildProcess.Command): void => { + switch (cmd._tag) { + case "StandardCommand": + commands.push(cmd) + return + case "PipedCommand": + walk(cmd.left) + opts.push(cmd.options) + walk(cmd.right) + return + } + } + + walk(command) + if (commands.length === 0) throw new Error("flatten produced empty commands array") + const [head, ...tail] = commands + return { + commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, + opts, + } +} + +const toPlatformError = ( + method: string, + err: NodeJS.ErrnoException, + command: ChildProcess.Command, +): PlatformError.PlatformError => { + const cmd = flatten(command) + .commands.map((x) => `${x.command} ${x.args.join(" ")}`) + .join(" | ") + return PlatformError.systemError({ + _tag: toTag(err), + module: "ChildProcess", + method, + pathOrDescriptor: cmd, + syscall: err.syscall, + cause: err, + }) +} + +type ExitSignal = Deferred.Deferred + +export const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) { + if (Predicate.isUndefined(opts.cwd)) return undefined + yield* fs.access(opts.cwd) + return path.resolve(opts.cwd) + }) + + const env = (opts: ChildProcess.CommandOptions) => + opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env + + const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined => + Stream.isStream(x) ? "pipe" : x + + const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined => + Sink.isSink(x) ? "pipe" : x + + const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => { + const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true } + if (Predicate.isUndefined(opts.stdin)) return cfg + if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin } + if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin } + return { + stream: opts.stdin.stream, + encoding: opts.stdin.encoding ?? cfg.encoding, + endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone, + } + } + + const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => { + const cfg = opts[key] + if (Predicate.isUndefined(cfg)) return { stream: "pipe" } + if (typeof cfg === "string") return { stream: cfg } + if (Sink.isSink(cfg)) return { stream: cfg } + return { stream: cfg.stream } + } + + const fds = (opts: ChildProcess.CommandOptions) => { + if (Predicate.isUndefined(opts.additionalFds)) return [] + return Object.entries(opts.additionalFds) + .flatMap(([name, config]) => { + const fd = ChildProcess.parseFdName(name) + return Predicate.isUndefined(fd) ? [] : [{ fd, config }] + }) + .toSorted((a, b) => a.fd - b.fd) + } + + const stdios = ( + sin: ChildProcess.StdinConfig, + sout: ChildProcess.StdoutConfig, + serr: ChildProcess.StderrConfig, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ): NodeChildProcess.StdioOptions => { + const pipe = (x: NodeChildProcess.IOType | undefined) => + process.platform === "win32" && x === "pipe" ? "overlapped" : x + const arr: Array = [ + pipe(input(sin.stream)), + pipe(output(sout.stream)), + pipe(output(serr.stream)), + ] + if (extra.length === 0) return arr as NodeChildProcess.StdioOptions + const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2) + for (let i = 3; i <= max; i++) arr[i] = "ignore" + for (const x of extra) arr[x.fd] = pipe("pipe") + return arr as NodeChildProcess.StdioOptions + } + + const setupFds = Effect.fnUntraced(function* ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ) { + if (extra.length === 0) { + return { + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + } + } + + const ins = new Map>() + const outs = new Map>() + + for (const x of extra) { + const node = proc.stdio[x.fd] + switch (x.config.type) { + case "input": { + let sink: Sink.Sink = Sink.drain + if (node && "write" in node) { + sink = NodeSink.fromWritable({ + evaluate: () => node, + onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command), + endOnDone: true, + }) + } + if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink)) + ins.set(x.fd, sink) + break + } + case "output": { + let stream: Stream.Stream = Stream.empty + if (node && "read" in node) { + const tap = new PassThrough() + node.on("error", (err) => tap.destroy(toError(err))) + node.pipe(tap) + stream = NodeStream.fromReadable({ + evaluate: () => tap, + onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command), + }) + } + if (x.config.sink) stream = Stream.transduce(stream, x.config.sink) + outs.set(x.fd, stream) + break + } + } + } + + return { + getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain, + getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty, + } + }) + + const setupStdin = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + cfg: ChildProcess.StdinConfig, + ) => + Effect.suspend(() => { + let sink: Sink.Sink = Sink.drain + if (Predicate.isNotNull(proc.stdin)) { + sink = NodeSink.fromWritable({ + evaluate: () => proc.stdin!, + onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command), + endOnDone: cfg.endOnDone, + encoding: cfg.encoding, + }) + } + if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink) + return Effect.succeed(sink) + }) + + const setupOutput = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + out: ChildProcess.StdoutConfig, + err: ChildProcess.StderrConfig, + ) => { + let stdout = proc.stdout + ? NodeStream.fromReadable({ + evaluate: () => proc.stdout!, + onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command), + }) + : Stream.empty + let stderr = proc.stderr + ? NodeStream.fromReadable({ + evaluate: () => proc.stderr!, + onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command), + }) + : Stream.empty + + if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream) + if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream) + + return { stdout, stderr, all: Stream.merge(stdout, stderr) } + } + + const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + Effect.callback((resume) => { + const signal = Deferred.makeUnsafe() + const proc = launch(command.command, command.args, opts) + let end = false + let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined + proc.on("error", (err) => { + resume(Effect.fail(toPlatformError("spawn", err, command))) + }) + proc.on("exit", (...args) => { + exit = args + }) + proc.on("close", (...args) => { + if (end) return + end = true + Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) + }) + proc.on("spawn", () => { + resume(Effect.succeed([proc, signal])) + }) + return Effect.sync(() => { + proc.kill("SIGTERM") + }) + }) + + const killGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => { + if (globalThis.process.platform === "win32") { + return Effect.callback((resume) => { + NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => { + if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command))) + resume(Effect.void) + }) + }) + } + + return Effect.try({ + try: () => { + globalThis.process.kill(-proc.pid!, signal) + }, + catch: (err) => toPlatformError("kill", toError(err), command), + }) + } + + const killOne = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => + Effect.suspend(() => { + if (proc.kill(signal)) return Effect.void + return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) + }) + + const timeout = + ( + proc: NodeChildProcess.ChildProcess, + command: ChildProcess.StandardCommand, + opts: ChildProcess.KillOptions | undefined, + ) => + ( + f: ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => Effect.Effect, + ) => { + const signal = opts?.killSignal ?? "SIGTERM" + if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal) + return Effect.timeoutOrElse(f(command, proc, signal), { + duration: opts.forceKillAfter, + onTimeout: () => f(command, proc, "SIGKILL"), + }) + } + + const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => { + const opt = from ?? "stdout" + switch (opt) { + case "stdout": + return handle.stdout + case "stderr": + return handle.stderr + case "all": + return handle.all + default: { + const fd = ChildProcess.parseFdName(opt) + return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout + } + } + } + + const spawnCommand: ( + command: ChildProcess.Command, + ) => Effect.Effect = Effect.fnUntraced( + function* (command) { + switch (command._tag) { + case "StandardCommand": { + const sin = stdin(command.options) + const sout = stdio(command.options, "stdout") + const serr = stdio(command.options, "stderr") + const extra = fds(command.options) + const dir = yield* cwd(command.options) + + const [proc, signal] = yield* Effect.acquireRelease( + spawn(command, { + cwd: dir, + env: env(command.options), + stdio: stdios(sin, sout, serr, extra), + detached: command.options.detached ?? process.platform !== "win32", + shell: command.options.shell, + windowsHide: process.platform === "win32", + }), + Effect.fnUntraced(function* ([proc, signal]) { + const done = yield* Deferred.isDone(signal) + const kill = timeout(proc, command, command.options) + if (done) { + const [code] = yield* Deferred.await(signal) + if (process.platform === "win32") return yield* Effect.void + if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) + return yield* Effect.void + } + return yield* kill((command, proc, signal) => + Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), + ).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore) + }), + ) + + const fd = yield* setupFds(command, proc, extra) + const out = setupOutput(command, proc, sout, serr) + return makeHandle({ + pid: ProcessId(proc.pid!), + stdin: yield* setupStdin(command, proc, sin), + stdout: out.stdout, + stderr: out.stderr, + all: out.all, + getInputFd: fd.getInputFd, + getOutputFd: fd.getOutputFd, + isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), + exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { + if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) + return Effect.fail( + toPlatformError( + "exitCode", + new Error(`Process interrupted due to receipt of signal: '${signal}'`), + command, + ), + ) + }), + kill: (opts?: ChildProcess.KillOptions) => + timeout( + proc, + command, + opts, + )((command, proc, signal) => + Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), + ).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + }) + } + case "PipedCommand": { + const flat = flatten(command) + const [head, ...tail] = flat.commands + let handle = spawnCommand(head) + for (let i = 0; i < tail.length; i++) { + const next = tail[i] + const opts = flat.opts[i] ?? {} + const sin = stdin(next.options) + const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from))) + const to = opts.to ?? "stdin" + if (to === "stdin") { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + const fd = ChildProcess.parseFdName(to) + if (Predicate.isUndefined(fd)) { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + additionalFds: { + ...next.options.additionalFds, + [ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream }, + }, + }), + ) + } + return yield* handle + } + } + }, + ) + + return makeSpawner(spawnCommand) +}) + +export const layer: Layer.Layer = Layer.effect( + ChildProcessSpawner, + make, +) diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index bac1bc7d96..226c276ead 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -3,18 +3,11 @@ import * as ServiceMap from "effect/ServiceMap" export const memoMap = Layer.makeMemoMapUnsafe() -// Accept layers whose RIn may not fully resolve to `never` due to -// TypeScript inference limitations with chained Layer.provide calls -// in Effect 4.x beta. At runtime the providers are present; the cast -// inside ManagedRuntime.make is safe. -export function makeRunPromise( - service: ServiceMap.Service, - layer: Layer.Layer, -) { +export function makeRunPromise(service: ServiceMap.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined return (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => { - rt ??= ManagedRuntime.make(layer as Layer.Layer, { memoMap }) + rt ??= ManagedRuntime.make(layer, { memoMap }) return rt.runPromise(service.use(fn), options) } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4c5d2cfc11..9e9da0cbfd 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -1,4 +1,3 @@ -// kilocode_change - renamed all OPENCODE_ env vars to KILO_ import { Config } from "effect" function truthy(key: string) { @@ -44,9 +43,9 @@ export namespace Flag { export const KILO_EXPERIMENTAL_FILEWATCHER = Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( Config.withDefault(false), ) - export const KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean( - "KILO_EXPERIMENTAL_DISABLE_FILEWATCHER", - ).pipe(Config.withDefault(false)) + export const KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( + Config.withDefault(false), + ) export const KILO_EXPERIMENTAL_ICON_DISCOVERY = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY") const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] @@ -66,6 +65,7 @@ export namespace Flag { export const KILO_EXPERIMENTAL_MARKDOWN = !falsy("KILO_EXPERIMENTAL_MARKDOWN") export const KILO_MODELS_URL = process.env["KILO_MODELS_URL"] export const KILO_MODELS_PATH = process.env["KILO_MODELS_PATH"] + export const KILO_DB = process.env["KILO_DB"] export const KILO_DISABLE_CHANNEL_DB = truthy("KILO_DISABLE_CHANNEL_DB") export const KILO_SKIP_MIGRATIONS = truthy("KILO_SKIP_MIGRATIONS") export const KILO_STRICT_CONFIG_DEPS = truthy("KILO_STRICT_CONFIG_DEPS") diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 7d24b42250..c14931a85d 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,7 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { makeRunPromise } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -174,7 +175,7 @@ export namespace Installation { ) const methodImpl = Effect.fn("Installation.method")(function* () { - if (process.execPath.includes(path.join(".kilo", "bin")) || process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method // kilocode_change + if (process.execPath.includes(path.join(".kilo", "bin"))) return "curl" as Method // kilocode_change if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method const exec = process.execPath.toLowerCase() @@ -201,7 +202,7 @@ export namespace Installation { for (const check of checks) { const output = yield* check.command() const installedName = - check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "kilo" : "@kilocode/cli" // kilocode_change + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "kilo" : "kilo" // kilocode_change if (output.includes(installedName)) { return check.name } @@ -221,7 +222,8 @@ export namespace Installation { return info.formulae[0].versions.stable } const response = yield* httpOk.execute( - HttpClientRequest.get("https://formulae.brew.sh/api/formula/kilo.json").pipe( // kilocode_change + HttpClientRequest.get("https://formulae.brew.sh/api/formula/kilo.json").pipe( + // kilocode_change HttpClientRequest.acceptJson, ), ) @@ -262,7 +264,8 @@ export namespace Installation { } const response = yield* httpOk.execute( - HttpClientRequest.get("https://api.github.com/repos/Kilo-Org/kilocode/releases/latest").pipe( // kilocode_change + HttpClientRequest.get("https://api.github.com/repos/Kilo-Org/kilocode/releases/latest").pipe( + // kilocode_change HttpClientRequest.acceptJson, ), ) @@ -345,10 +348,10 @@ export namespace Installation { export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), - ) as Layer.Layer + ) const runPromise = makeRunPromise(Service, defaultLayer) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index bf404b4a81..0aed80e8c0 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -187,8 +187,9 @@ export namespace Permission { ) const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - const s = yield* InstanceState.get(state) + const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input + const s = yield* InstanceState.get(state) // kilocode_change const local = s.session[request.sessionID] ?? [] // kilocode_change let needsAsk = false @@ -197,7 +198,7 @@ export namespace Permission { // kilocode_change end for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, s.approved, local) // kilocode_change — include session rules + const rule = evaluate(request.permission, pattern, ruleset, approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { return yield* new DeniedError({ @@ -358,9 +359,7 @@ export namespace Permission { delete s.session[input.sessionID] return } - const idx = s.approved.findLastIndex( - (r) => r.permission === "*" && r.pattern === "*" && r.action === "allow", - ) + const idx = s.approved.findLastIndex((r) => r.permission === "*" && r.pattern === "*" && r.action === "allow") if (idx >= 0) s.approved.splice(idx, 1) return } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 83d0127aa0..63d045d558 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,11 +11,11 @@ import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" -import { makeRunPromise } from "@/effect/run-service" - import { KiloAuthPlugin } from "@kilocode/kilo-gateway" // kilocode_change +import { makeRunPromise } from "@/effect/run-service" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -52,6 +52,7 @@ export namespace Plugin { CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, + PoeAuthPlugin, ] // kilocode_change end // Old npm package names for plugins that are now built-in — skip if users still have them in config @@ -144,7 +145,11 @@ export namespace Plugin { // Notify plugins of current config for (const hook of hooks) { - await (hook as any).config?.(cfg) + try { + await (hook as any).config?.(cfg) + } catch (err) { + log.error("plugin config hook failed", { error: err }) + } } }) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index cf64048661..cfd16237fb 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -11,6 +11,7 @@ import { Command } from "../command" import { Instance } from "./instance" import { Log } from "@/util/log" import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change +// import { ShareNext } from "@/share/share-next" // kilocode_change export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index c2292f0638..4c9b2e107b 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -132,16 +132,6 @@ export const Instance = { cache.delete(directory) emit(directory) }, - /** - * Reset a specific state entry for all instances without running dispose callbacks. - * Used to invalidate config-derived caches (e.g. Config.state) after a no-dispose - * config write, so the next Config.get() re-reads from disk with correct precedence. - */ - resetStateEntry(init: (...args: any[]) => any) { - for (const dir of cache.keys()) { - State.resetEntry(dir, init) - } - }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a06407399a..4d0a72ac08 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,36 +1,23 @@ import z from "zod" -import { Filesystem } from "../util/filesystem" -import path from "path" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import { Log } from "../util/log" import { Flag } from "@/flag/flag" -import { fn } from "@opencode-ai/util/fn" import { BusEvent } from "@/bus/bus-event" -import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" -import { existsSync } from "fs" -import { git } from "../util/git" -import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" export namespace Project { const log = Log.create({ service: "project" }) - function gitpath(cwd: string, name: string) { - if (!name) return cwd - // git output includes trailing newlines; keep path whitespace intact. - name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd - - name = Filesystem.windowsPath(name) - - if (path.isAbsolute(name)) return path.normalize(name) - return path.resolve(cwd, name) - } - export const Info = z .object({ id: ProjectID.zod, @@ -73,7 +60,7 @@ export namespace Project { ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { - id: ProjectID.make(row.id), + id: row.id, worktree: row.worktree, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, @@ -88,245 +75,408 @@ export namespace Project { } } - function readCachedId(dir: string) { - return Filesystem.readText(path.join(dir, "kilo")) // kilocode_change - .then((x) => x.trim()) - .then(ProjectID.make) - .catch(() => undefined) + export const UpdateInput = z.object({ + projectID: ProjectID.zod, + name: z.string().optional(), + icon: Info.shape.icon.optional(), + commands: Info.shape.commands.optional(), + }) + export type UpdateInput = z.infer + + // --------------------------------------------------------------------------- + // Effect service + // --------------------------------------------------------------------------- + + export interface Interface { + readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> + readonly discover: (input: Info) => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (id: ProjectID) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect + readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect + readonly setInitialized: (id: ProjectID) => Effect.Effect + readonly sandboxes: (id: ProjectID) => Effect.Effect + readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect + readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect } - export async function fromDirectory(directory: string) { - log.info("fromDirectory", { directory }) + export class Service extends ServiceMap.Service()("@opencode/Project") {} - const data = await iife(async () => { - const matches = Filesystem.up({ targets: [".git"], start: directory }) - const dotgit = await matches.next().then((x) => x.value) - await matches.return() - if (dotgit) { - let sandbox = path.dirname(dotgit) + type GitResult = { code: number; text: string; stderr: string } - const gitBinary = which("git") + export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const pathSvc = yield* Path.Path + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - // cached id calculation - let id = await readCachedId(dotgit) + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), + ) - if (!gitBinary) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox: sandbox, - vcs: Info.shape.vcs.parse(Flag.KILO_FAKE_VCS), // kilocode_change - } - } + const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) - const worktree = await git(["rev-parse", "--git-common-dir"], { - cwd: sandbox, - }) - .then(async (result) => { - const common = gitpath(sandbox, await result.text()) - // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) - }) - .catch(() => undefined) + const emitUpdated = (data: Info) => + Effect.sync(() => + GlobalBus.emit("event", { + payload: { type: Event.Updated.type, properties: data }, + }), + ) - if (!worktree) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.KILO_FAKE_VCS), // kilocode_change - } - } + const fakeVcs = Info.shape.vcs.parse(Flag.KILO_FAKE_VCS) - // In the case of a git worktree, it can't cache the id - // because `.git` is not a folder, but it always needs the - // same project id as the common dir, so we resolve it now - if (id == null) { - id = await readCachedId(path.join(worktree, ".git")) - } + const resolveGitPath = (cwd: string, name: string) => { + if (!name) return cwd + name = name.replace(/[\r\n]+$/, "") + if (!name) return cwd + name = AppFileSystem.windowsPath(name) + if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name) + return pathSvc.resolve(cwd, name) + } - // generate id from root commit - if (!id) { - const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { - cwd: sandbox, - }) - .then(async (result) => - (await result.text()) - .split("\n") - .filter(Boolean) - .map((x) => x.trim()) - .toSorted(), - ) - .catch(() => undefined) + const scope = yield* Scope.Scope - if (!roots) { + const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { + // kilocode change start + return yield* fsys.readFileString(pathSvc.join(dir, "kilo")).pipe( + // kilocode change end + Effect.map((x) => x.trim()), + Effect.map(ProjectID.make), + Effect.catch(() => Effect.succeed(undefined)), + ) + }) + + const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { + log.info("fromDirectory", { directory }) + + // Phase 1: discover git info + type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] } + + const data: DiscoveryResult = yield* Effect.gen(function* () { + const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) + const dotgit = dotgitMatches[0] + + if (!dotgit) { return { id: ProjectID.global, - worktree: sandbox, - sandbox: sandbox, - vcs: Info.shape.vcs.parse(Flag.KILO_FAKE_VCS), // kilocode_change + worktree: "/", + sandbox: "/", + vcs: fakeVcs, } } - id = roots[0] ? ProjectID.make(roots[0]) : undefined - if (id) { - // Write to common dir so the cache is shared across worktrees. - await Filesystem.write(path.join(worktree, ".git", "kilo"), id).catch(() => undefined) // kilocode_change - } - } + let sandbox = pathSvc.dirname(dotgit) + const gitBinary = yield* Effect.sync(() => which("git")) + let id = yield* readCachedProjectId(dotgit) - if (!id) { - return { - id: ProjectID.global, - worktree: sandbox, - sandbox, - vcs: "git", + if (!gitBinary) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } } - } - const top = await git(["rev-parse", "--show-toplevel"], { - cwd: sandbox, + const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox }) + if (commonDir.code !== 0) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + const worktree = (() => { + const common = resolveGitPath(sandbox, commonDir.text.trim()) + return common === sandbox ? sandbox : pathSvc.dirname(common) + })() + + if (id == null) { + id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) + } + + if (!id) { + const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox }) + const roots = revList.text + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() + + id = roots[0] ? ProjectID.make(roots[0]) : undefined + if (id) { + // kilocode_change start + yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "kilo"), id).pipe(Effect.ignore) + // kilocode_change end + } + } + + if (!id) { + return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const } + } + + const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox }) + if (topLevel.code !== 0) { + return { + id, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + sandbox = resolveGitPath(sandbox, topLevel.text.trim()) + + return { id, sandbox, worktree, vcs: "git" as const } }) - .then(async (result) => gitpath(sandbox, await result.text())) - .catch(() => undefined) - if (!top) { - return { - id, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.KILO_FAKE_VCS), - } - } + // Phase 2: upsert + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) + const existing = row + ? fromRow(row) + : { + id: data.id, + worktree: data.worktree, + vcs: data.vcs, + sandboxes: [] as string[], + time: { created: Date.now(), updated: Date.now() }, + } - sandbox = top + if (Flag.KILO_EXPERIMENTAL_ICON_DISCOVERY) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - return { - id, - sandbox, - worktree, - vcs: "git", - } - } - - return { - id: ProjectID.global, - worktree: "/", - sandbox: "/", - vcs: Info.shape.vcs.parse(Flag.KILO_FAKE_VCS), - } - }) - - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row - ? fromRow(row) - : { - id: data.id, + const result: Info = { + ...existing, worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - sandboxes: [] as string[], - time: { - created: Date.now(), - updated: Date.now(), - }, + vcs: data.vcs, + time: { ...existing.time, updated: Date.now() }, + } + if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) + result.sandboxes.push(data.sandbox) + result.sandboxes = yield* Effect.forEach( + result.sandboxes, + (s) => + fsys.exists(s).pipe( + Effect.orDie, + Effect.map((exists) => (exists ? s : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + + yield* db((d) => + d + .insert(ProjectTable) + .values({ + id: result.id, + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_created: result.time.created, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }, + }) + .run(), + ) + + if (data.id !== ProjectID.global) { + yield* db((d) => + d + .update(SessionTable) + .set({ project_id: data.id }) + .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) + .run(), + ) } - if (Flag.KILO_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) + yield* emitUpdated(result) + return { project: result, sandbox: data.sandbox } + }) - const result: Info = { - ...existing, - worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - time: { - ...existing.time, - updated: Date.now(), - }, - } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) - const insert = { - id: result.id, - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_created: result.time.created, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - } - const updateSet = { - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - } - Database.use((db) => - db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), - ) - // Runs after upsert so the target project row exists (FK constraint). - // Runs on every startup because sessions created before git init - // accumulate under "global" and need migrating whenever they appear. - if (data.id !== ProjectID.global) { - Database.use((db) => - db - .update(SessionTable) - .set({ project_id: data.id }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) - .run(), - ) - } - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: result, - }, - }) - return { project: result, sandbox: data.sandbox } + const discover = Effect.fn("Project.discover")(function* (input: Info) { + if (input.vcs !== "git") return + if (input.icon?.override) return + if (input.icon?.url) return + + const matches = yield* fsys + .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { + cwd: input.worktree, + absolute: true, + include: "file", + }) + .pipe(Effect.orDie) + const shortest = matches.sort((a, b) => a.length - b.length)[0] + if (!shortest) return + + const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie) + const base64 = Buffer.from(buffer).toString("base64") + const mime = AppFileSystem.mimeType(shortest) + const url = `data:${mime};base64,${base64}` + yield* update({ projectID: input.id, icon: { url } }) + }) + + const list = Effect.fn("Project.list")(function* () { + return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) + }) + + const get = Effect.fn("Project.get")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + return row ? fromRow(row) : undefined + }) + + const update = Effect.fn("Project.update")(function* (input: UpdateInput) { + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${input.projectID}`) + const data = fromRow(result) + yield* emitUpdated(data) + return data + }) + + const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) { + if (input.project.vcs === "git") return input.project + if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed") + const result = yield* git(["init", "--quiet"], { cwd: input.directory }) + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository") + } + const { project } = yield* fromDirectory(input.directory) + return project + }) + + const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) { + yield* db((d) => + d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), + ) + }) + + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) return [] + const data = fromRow(row) + return yield* Effect.forEach( + data.sandboxes, + (dir) => + fsys.isDir(dir).pipe( + Effect.orDie, + Effect.map((ok) => (ok ? dir : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + }) + + const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = [...row.sandboxes] + if (!sboxes.includes(directory)) sboxes.push(directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = row.sandboxes.filter((s) => s !== directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + return Service.of({ + fromDirectory, + discover, + list, + get, + update, + initGit, + setInitialized, + sandboxes, + addSandbox, + removeSandbox, + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(CrossSpawnSpawner.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) + const runPromise = makeRunPromise(Service, defaultLayer) + + // --------------------------------------------------------------------------- + // Promise-based API (delegates to Effect service via runPromise) + // --------------------------------------------------------------------------- + + export function fromDirectory(directory: string) { + return runPromise((svc) => svc.fromDirectory(directory)) } - export async function discover(input: Info) { - if (input.vcs !== "git") return - if (input.icon?.override) return - if (input.icon?.url) return - const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { - cwd: input.worktree, - absolute: true, - include: "file", - }) - const shortest = matches.sort((a, b) => a.length - b.length)[0] - if (!shortest) return - const buffer = await Filesystem.readBytes(shortest) - const base64 = buffer.toString("base64") - const mime = Filesystem.mimeType(shortest) || "image/png" - const url = `data:${mime};base64,${base64}` - await update({ - projectID: input.id, - icon: { - url, - }, - }) - return - } - - export function setInitialized(id: ProjectID) { - Database.use((db) => - db - .update(ProjectTable) - .set({ - time_initialized: Date.now(), - }) - .where(eq(ProjectTable.id, id)) - .run(), - ) + export function discover(input: Info) { + return runPromise((svc) => svc.discover(input)) } export function list() { @@ -345,112 +495,29 @@ export namespace Project { return fromRow(row) } - export async function initGit(input: { directory: string; project: Info }) { - if (input.project.vcs === "git") return input.project - if (!which("git")) throw new Error("Git is not installed") - - const result = await git(["init", "--quiet"], { - cwd: input.directory, - }) - if (result.exitCode !== 0) { - const text = result.stderr.toString().trim() || result.text().trim() - throw new Error(text || "Failed to initialize git repository") - } - - return (await fromDirectory(input.directory)).project - } - - export const update = fn( - z.object({ - projectID: ProjectID.zod, - name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), - }), - async (input) => { - const id = ProjectID.make(input.projectID) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ - name: input.name, - icon_url: input.icon?.url, - icon_color: input.icon?.color, - commands: input.commands, - time_updated: Date.now(), - }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${input.projectID}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data - }, - ) - - export async function sandboxes(id: ProjectID) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) return [] - const data = fromRow(row) - const valid: string[] = [] - for (const dir of data.sandboxes) { - const s = Filesystem.stat(dir) - if (s?.isDirectory()) valid.push(dir) - } - return valid - } - - export async function addSandbox(id: ProjectID, directory: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = [...row.sandboxes] - if (!sandboxes.includes(directory)) sandboxes.push(directory) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ sandboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), + export function setInitialized(id: ProjectID) { + Database.use((db) => + db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), ) - if (!result) throw new Error(`Project not found: ${id}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data } - export async function removeSandbox(id: ProjectID, directory: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = row.sandboxes.filter((s) => s !== directory) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ sandboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${id}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data + export function initGit(input: { directory: string; project: Info }) { + return runPromise((svc) => svc.initGit(input)) + } + + export function update(input: UpdateInput) { + return runPromise((svc) => svc.update(input)) + } + + export function sandboxes(id: ProjectID) { + return runPromise((svc) => svc.sandboxes(id)) + } + + export function addSandbox(id: ProjectID, directory: string) { + return runPromise((svc) => svc.addSandbox(id, directory)) + } + + export function removeSandbox(id: ProjectID, directory: string) { + return runPromise((svc) => svc.removeSandbox(id, directory)) } } diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e2bce9b793..2304bed63b 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -260,7 +260,7 @@ export namespace ProviderAuth { } } Telemetry.trackAuthSuccess(input.providerID) + ModelCache.clear(input.providerID) // kilocode_change end - ModelCache.clear(input.providerID) // kilocode_change } } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d184c39cb8..6977f6bf2a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -197,7 +197,10 @@ export namespace ProviderTransform { } for (const msg of unique([...system, ...final])) { - const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock") + const useMessageLevelOptions = + model.providerID === "anthropic" || + model.providerID.includes("bedrock") || + model.api.npm === "@ai-sdk/amazon-bedrock" const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 3a075713f0..e591efb5bb 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -201,8 +201,8 @@ export const GlobalRoutes = lazy(() => .post( "/upgrade", describeRoute({ - summary: "Upgrade kilo", - description: "Upgrade kilo to the specified version or latest if not specified.", + summary: "Upgrade kilo", // kilocode_change + description: "Upgrade kilo to the specified version or latest if not specified.", // kilocode_change operationId: "global.upgrade", responses: { 200: { diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts index fbc35eec3f..def6066f00 100644 --- a/packages/opencode/src/server/routes/permission.ts +++ b/packages/opencode/src/server/routes/permission.ts @@ -32,13 +32,7 @@ export const PermissionRoutes = lazy(() => requestID: PermissionID.zod, }), ), - validator( - "json", - z.object({ - reply: Permission.Reply, - message: z.string().optional(), - }), - ), + validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 6cd51ac958..e5dd5782d6 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() => }, }), validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.update.schema.omit({ projectID: true })), + validator("json", Project.UpdateInput.omit({ projectID: true })), async (c) => { const projectID = c.req.valid("param").projectID const body = c.req.valid("json") diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 62de16f164..ada8ffdc7b 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Bus } from "../../bus" +import { NamedError } from "@opencode-ai/util/error" const log = Log.create({ service: "server" }) @@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() => return stream(c, async () => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }) + SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }) }) }, ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ac402688bb..fc3059cb87 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -32,19 +32,6 @@ import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" import { EventRoutes } from "./routes/event" -import { TelemetryRoutes } from "./routes/telemetry" // kilocode_change -import { CommitMessageRoutes } from "./routes/commit-message" // kilocode_change -import { EnhancePromptRoutes } from "./routes/enhance-prompt" // kilocode_change -import { KilocodeRoutes } from "./routes/kilocode" // kilocode_change -import { PermissionKilocodeRoutes } from "../kilocode/permission/routes" // kilocode_change -import { RemoteRoutes } from "./routes/remote" // kilocode_change -import { NetworkRoutes } from "./routes/network" // kilocode_change -import { createKiloRoutes } from "@kilocode/kilo-gateway" // kilocode_change -import { Database } from "../storage/db" // kilocode_change -import { Session } from "../session" // kilocode_change -import { Identifier } from "../id/id" // kilocode_change -import { SessionTable, MessageTable, PartTable } from "../session/session.sql" // kilocode_change -import { Bus } from "@/bus" // kilocode_change import { InstanceBootstrap } from "../project/bootstrap" import { NotFoundError } from "../storage/db" import type { ContentfulStatusCode } from "hono/utils/http-status" @@ -58,6 +45,24 @@ import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" + +// kilocode_change start +// KILO ROUTES +import { TelemetryRoutes } from "./routes/telemetry" +import { CommitMessageRoutes } from "./routes/commit-message" +import { EnhancePromptRoutes } from "./routes/enhance-prompt" +import { KilocodeRoutes } from "./routes/kilocode" +import { PermissionKilocodeRoutes } from "../kilocode/permission/routes" +import { RemoteRoutes } from "./routes/remote" +import { NetworkRoutes } from "./routes/network" +import { Database } from "../storage/db" +import { Session } from "../session" +import { Identifier } from "../id/id" +import { SessionTable, MessageTable, PartTable } from "../session/session.sql" +import { Bus } from "@/bus" +import { createKiloRoutes } from "@kilocode/kilo-gateway" +// kilocode_change end + // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -89,14 +94,19 @@ export namespace Server { status: 500, }) }) - .use((c, next) => { - // Allow CORS preflight requests to succeed without auth. - // Browser clients sending Authorization headers will preflight with OPTIONS. - if (c.req.method === "OPTIONS") return next() - const password = Flag.KILO_SERVER_PASSWORD // kilocode_change - if (!password) return next() - const username = Flag.KILO_SERVER_USERNAME ?? "kilo" // kilocode_change - return basicAuth({ username, password })(c, next) + if (err instanceof NamedError) { + let status: ContentfulStatusCode + if (err instanceof NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name === "ProviderAuthValidationFailed") status = 400 + else if (err.name.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + if (err instanceof HTTPException) return err.getResponse() + const message = err instanceof Error && err.stack ? err.stack : err.toString() + return c.json(new NamedError.Unknown({ message }).toObject(), { + status: 500, }) .use(async (c, next) => { // kilocode_change start @@ -112,41 +122,357 @@ export namespace Server { method: c.req.method, path: c.req.path, }) - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, + } + const timer = log.time("request", { + method: c.req.method, + path: c.req.path, + }) + await next() + if (!skipLogging) { + timer.stop() + } + }) + .use( + cors({ + origin(input) { + if (!input) return + + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if ( + input === "tauri://localhost" || + input === "http://tauri.localhost" || + input === "https://tauri.localhost" + ) + return input + + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + if (opts?.cors?.includes(input)) { + return input + } + + return + }, + }), + ) + .route("/global", GlobalRoutes()) + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + validator("json", Auth.Info.zod), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) + .use(async (c, next) => { + if (c.req.path === "/log") return next() + const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = Filesystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + return WorkspaceContext.provide({ + workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined, + async fn() { + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }) + }, + }) + }) + .use(WorkspaceRouterMiddleware) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }), + ) + .use( + validator( + "query", + z.object({ + directory: z.string().optional(), + workspace: z.string().optional(), + }), + ), + ) + .route("/project", ProjectRoutes()) + .route("/pty", PtyRoutes()) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) + .route("/question", QuestionRoutes()) + .route("/provider", ProviderRoutes()) + .route("/", FileRoutes()) + .route("/", EventRoutes()) + .route("/mcp", McpRoutes()) + .route("/tui", TuiRoutes()) + .post( + "/instance/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Instance.dispose() + return c.json(true) + }, + ) + .get( + "/path", + describeRoute({ + summary: "Get paths", + description: "Retrieve the current working directory and related path information for the OpenCode instance.", + operationId: "path.get", + responses: { + 200: { + description: "Path", + content: { + "application/json": { + schema: resolver( + z + .object({ + home: z.string(), + state: z.string(), + config: z.string(), + worktree: z.string(), + directory: z.string(), + }) + .meta({ + ref: "Path", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json({ + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: Instance.worktree, + directory: Instance.directory, }) await next() if (!skipLogging) { timer.stop() } - }) - .use( - cors({ - origin(input) { - if (!input) return - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if ( - input === "tauri://localhost" || - input === "http://tauri.localhost" || - input === "https://tauri.localhost" - ) - return input - - // *.opencode.ai (https only, adjust if needed) - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - return input - } - if (opts?.cors?.includes(input)) { - return input - } - - return + return c.json(true) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, }, - }), - ) + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/skill", + describeRoute({ + summary: "List skills", + description: "Get a list of all available skills in the OpenCode system.", + operationId: "app.skills", + responses: { + 200: { + description: "List of skills", + content: { + "application/json": { + schema: resolver(Skill.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const skills = await Skill.all() + return c.json(skills) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Format.status()) + }, + ) + // kilocode_change start - disable proxy + // .all("/*", async (c) => { + // const path = c.req.path + + // const response = await proxy(`https://app.opencode.ai${path}`, { + // ...c.req, + // headers: { + // ...c.req.raw.headers, + // host: "app.opencode.ai", + // }, + // }) + // .use( + // cors({ + // origin(input) { + // if (!input) return + + // if (input.startsWith("http://localhost:")) return input + // if (input.startsWith("http://127.0.0.1:")) return input + // if ( + // input === "tauri://localhost" || + // input === "http://tauri.localhost" || + // input === "https://tauri.localhost" + // ) + // return input + + // // *.opencode.ai (https only, adjust if needed) + // if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + // return input + // } + // if (opts?.cors?.includes(input)) { + // return input + // } + + // return + // }, + // }), + // ) .route("/global", GlobalRoutes()) .put( "/auth/:providerID", @@ -560,8 +886,8 @@ export namespace Server { .all("/*", async (c) => { return c.notFound() }) + // kilocode_change end ) - // kilocode_change end } export async function openapi() { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index f165201177..4d91059d6d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -125,17 +125,20 @@ export namespace LLM { // kilocode_change end } + const isWorkflow = language instanceof GitLabWorkflowLanguageModel const messages = isOpenaiOauth ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] + : isWorkflow + ? input.messages + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] const params = await Plugin.trigger( "chat.params", @@ -208,6 +211,7 @@ export namespace LLM { // and results sent back over the WebSocket. if (language instanceof GitLabWorkflowLanguageModel) { const workflowModel = language + workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { const t = tools[toolName] if (!t || !t.execute) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a7134ed394..163d0504f8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,7 +48,6 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncate" -import { Identifier } from "../id/id" import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change import { environmentDetails } from "@/kilocode/editor-context" // kilocode_change import { decodeDataUrl } from "@/util/data-url" @@ -471,6 +470,16 @@ export namespace SessionPrompt { ) let executionError: Error | undefined const taskAgent = await Agent.get(task.agent) + if (!taskAgent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID, + error: error.toObject(), + }) + throw error + } const taskCtx: Tool.Context = { agent: task.agent, messageID: assistantMessage.id, @@ -614,6 +623,16 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID, + error: error.toObject(), + }) + throw error + } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -1053,7 +1072,18 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const agentName = input.agent || (await Agent.defaultAgent()) + const agent = await Agent.get(agentName) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -1631,6 +1661,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the await SessionRevert.cleanup(session) } const agent = await Agent.get(input.agent) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), @@ -1892,7 +1932,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the log.info("command", input) const command = await Command.get(input.command) if (!command) { - throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) + const available = await Command.list().then((cmds) => cmds.map((c) => c.name)) + const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 7d8726b3d5..f225589978 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -32,30 +32,12 @@ export namespace SystemPrompt { // kilocode_change end export function provider(model: Provider.Model) { - // kilocode_change start - switch (model.prompt) { - case "anthropic": - return [PROMPT_ANTHROPIC] - case "anthropic_without_todo": - return [PROMPT_DEFAULT] - case "beast": - return [PROMPT_BEAST] - case "codex": - return [PROMPT_CODEX] - case "gemini": - return [PROMPT_GEMINI] - case "trinity": - return [PROMPT_TRINITY] - } - // kilocode_change end - if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) return [PROMPT_BEAST] if (model.api.id.includes("gpt")) return [PROMPT_CODEX] if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC] if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY] - return [PROMPT_DEFAULT] } diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index ff0bfde495..e103975032 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -108,9 +108,9 @@ export namespace Discovery { }), ) - export const defaultLayer = layer.pipe( + export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), - ) as Layer.Layer + ) } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 456f265ab9..15987463f2 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,4 +1,3 @@ -import { rm } from "fs/promises" import os from "os" import path from "path" import { pathToFileURL } from "url" @@ -18,6 +17,7 @@ import { ConfigMarkdown } from "../config/markdown" import { Glob } from "../util/glob" import { Log } from "../util/log" import { Discovery } from "./discovery" +import { rm } from "fs/promises" // kilocode_change export namespace Skill { const log = Log.create({ service: "skill" }) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ccee4c65c1..2beda72100 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,9 +1,9 @@ -import { $ } from "bun" -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" import z from "zod" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" @@ -219,6 +219,18 @@ export namespace Snapshot { const track = Effect.fnUntraced(function* () { if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } yield* add() const result = yield* git(args(["write-tree"]), { cwd: state.directory }) const hash = result.text.trim() @@ -518,7 +530,7 @@ export namespace Snapshot { ) export const defaultLayer = layer.pipe( - Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(CrossSpawnSpawner.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 2945a7b6c0..0605201b9d 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -10,7 +10,6 @@ import { NamedError } from "@opencode-ai/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" -import * as schema from "./schema" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" @@ -28,8 +27,17 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export namespace Database { - // kilocode_change - always use kilo.db regardless of channel - export const Path = path.join(Global.Path.data, "kilo.db") + export const Path = iife(() => { + if (Flag.KILO_DB) { + if (path.isAbsolute(Flag.KILO_DB)) return Flag.KILO_DB + return path.join(Global.Path.data, Flag.KILO_DB) + } + const channel = Installation.CHANNEL + if (["latest", "beta"].includes(channel) || Flag.KILO_DISABLE_CHANNEL_DB) + return path.join(Global.Path.data, "kilo.db") // kilocode_change + const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-") + return path.join(Global.Path.data, `kilo-${safe}.db`) // kilocode_change + }) export type Transaction = SQLiteTransaction<"sync", void> diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 959cf66d9a..62b8d38456 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -113,7 +113,8 @@ export namespace ToolRegistry { async function all(custom: Tool.Info[]): Promise { const cfg = await Config.get() - const question = ["app", "cli", "desktop"].includes(Flag.KILO_CLIENT) || Flag.KILO_ENABLE_QUESTION_TOOL + const question = + ["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) || Flag.KILO_ENABLE_QUESTION_TOOL return [ InvalidTool, @@ -223,4 +224,4 @@ export namespace ToolRegistry { ) { return runPromise((svc) => svc.tools(model, agent)) } -} \ No newline at end of file +} diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 92ccff4831..031ca6f241 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -133,9 +133,7 @@ export namespace Truncate { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - ) as Layer.Layer + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) as Layer.Layer const runPromise = makeRunPromise(Service, defaultLayer) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 53a69455fe..18ef396c7c 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -123,8 +123,10 @@ test("plan agent denies edits except .kilo/plans/* and .opencode/plans/*", async expect(plan).toBeDefined() // Wildcard is denied expect(evalPerm(plan, "edit")).toBe("deny") + // kilocode_change start // .kilo/plans/ is the primary allowed path expect(Permission.evaluate("edit", ".kilo/plans/foo.md", plan!.permission).action).toBe("allow") + // kilocode_change end // .opencode/plans/ is also allowed as backward compat fallback expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") }, diff --git a/packages/opencode/test/cli/account.test.ts b/packages/opencode/test/cli/account.test.ts new file mode 100644 index 0000000000..d75d7bd255 --- /dev/null +++ b/packages/opencode/test/cli/account.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import stripAnsi from "strip-ansi" + +import { formatAccountLabel, formatOrgLine } from "../../src/cli/cmd/account" + +describe("console account display", () => { + test("includes the account url in account labels", () => { + expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, false))).toBe( + "one@example.com https://one.example.com", + ) + }) + + test("includes the active marker in account labels", () => { + expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, true))).toBe( + "one@example.com https://one.example.com (active)", + ) + }) + + test("includes the account url in org rows", () => { + expect( + stripAnsi( + formatOrgLine({ email: "one@example.com", url: "https://one.example.com" }, { id: "org-1", name: "One" }, true), + ), + ).toBe(" ● One one@example.com https://one.example.com org-1") + }) +}) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts new file mode 100644 index 0000000000..08cae76e2f --- /dev/null +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -0,0 +1,402 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Exit, Layer, Stream } from "effect" +import type * as PlatformError from "effect/PlatformError" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) +const fx = testEffect(live) + +function js(code: string, opts?: ChildProcess.CommandOptions) { + return ChildProcess.make("node", ["-e", code], opts) +} + +function decodeByteStream(stream: Stream.Stream) { + return Stream.runCollect(stream).pipe( + Effect.map((chunks) => { + const total = chunks.reduce((acc, x) => acc + x.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const chunk of chunks) { + out.set(chunk, off) + off += chunk.length + } + return new TextDecoder("utf-8").decode(out).trim() + }), + ) +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function gone(pid: number, timeout = 5_000) { + const end = Date.now() + timeout + while (Date.now() < end) { + if (!alive(pid)) return true + await Bun.sleep(50) + } + return !alive(pid) +} + +describe("cross-spawn spawner", () => { + describe("basic spawning", () => { + fx.effect( + "captures stdout", + Effect.gen(function* () { + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), + ) + expect(out).toBe("ok") + }), + ) + + fx.effect( + "captures multiple lines", + Effect.gen(function* () { + const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("line1\nline2\nline3") + }), + ) + + fx.effect( + "returns exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(0)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + + fx.effect( + "returns non-zero exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(42)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(42)) + }), + ) + }) + + describe("cwd option", () => { + fx.effect( + "uses cwd when spawning commands", + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), + ), + ) + expect(out).toBe(tmp.path) + }), + ) + + fx.effect( + "fails for invalid cwd", + Effect.gen(function* () { + const exit = yield* Effect.exit( + ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), + ) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + describe("env option", () => { + fx.effect( + "passes environment variables with extendEnv", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { + env: { TEST_VAR: "test_value" }, + extendEnv: true, + }) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("test_value") + }), + ) + + fx.effect( + "passes multiple environment variables", + Effect.gen(function* () { + const handle = yield* js( + "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", + { + env: { VAR1: "one", VAR2: "two", VAR3: "three" }, + extendEnv: true, + }, + ) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("one-two-three") + }), + ) + }) + + describe("stderr", () => { + fx.effect( + "captures stderr output", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error message")') + const err = yield* decodeByteStream(handle.stderr) + expect(err).toBe("error message") + }), + ) + + fx.effect( + "captures both stdout and stderr", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")') + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + expect(stdout).toBe("stdout") + expect(stderr).toBe("stderr") + }), + ) + }) + + describe("combined output (all)", () => { + fx.effect( + "captures stdout via .all when no stderr", + Effect.gen(function* () { + const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stdout") + }), + ) + + fx.effect( + "captures stderr via .all when no stdout", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("hello from stderr")') + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stderr") + }), + ) + }) + + describe("stdin", () => { + fx.effect( + "allows providing standard input to a command", + Effect.gen(function* () { + const input = "a b c" + const stdin = Stream.make(Buffer.from(input, "utf-8")) + const handle = yield* js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + { stdin }, + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("a b c") + }), + ) + }) + + describe("process control", () => { + fx.effect( + "kills a running process", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js("setTimeout(() => {}, 10_000)") + yield* handle.kill() + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "kills a child when scope exits", + Effect.gen(function* () { + const pid = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* js("setInterval(() => {}, 10_000)") + return Number(handle.pid) + }), + ) + const done = yield* Effect.promise(() => gone(pid)) + expect(done).toBe(true) + }), + ) + + fx.effect( + "forceKillAfter escalates for stubborn processes", + Effect.gen(function* () { + if (process.platform === "win32") return + + const started = Date.now() + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') + yield* handle.kill({ forceKillAfter: 100 }) + return yield* handle.exitCode + }), + ) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "isRunning reflects process state", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("done")') + yield* handle.exitCode + const running = yield* handle.isRunning + expect(running).toBe(false) + }), + ) + }) + + describe("error handling", () => { + fx.effect( + "fails for invalid command", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("nonexistent-command-12345") + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + }) + + describe("pipeline", () => { + fx.effect( + "pipes stdout of one command to stdin of another", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO WORLD") + }), + ) + + fx.effect( + "three-stage pipeline", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO-WORLD") + }), + ) + + fx.effect( + "pipes stderr with { from: 'stderr' }", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "stderr" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("error") + }), + ) + + fx.effect( + "pipes combined output with { from: 'all' }", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "all" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toContain("stdout") + expect(out).toContain("stderr") + }), + ) + }) + + describe("Windows-specific", () => { + fx.effect( + "uses shell routing on Windows", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { + shell: true, + extendEnv: true, + env: { OPENCODE_TEST_SHELL: "ok" }, + }), + ), + ) + expect(out).toContain("OPENCODE_TEST_SHELL=ok") + }), + ) + + fx.effect( + "runs cmd scripts with spaces on Windows without shell", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const dir = path.join(tmp.path, "with space") + const file = path.join(dir, "echo cmd.cmd") + + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) + yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) + + const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.exitCode( + ChildProcess.make(file, ["--stdio"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ), + ) + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + }) +}) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 8cbd478cba..6658634e54 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -177,13 +177,17 @@ describeWatcher("FileWatcher", () => { await withWatcher(tmp.path, Effect.void) // Now write a file — no watcher should be listening - await Effect.runPromise( - noUpdate( - tmp.path, - (e) => e.file === file, - Effect.promise(() => fs.writeFile(file, "gone")), - ), - ) + await Instance.provide({ + directory: tmp.path, + fn: () => + Effect.runPromise( + noUpdate( + tmp.path, + (e) => e.file === file, + Effect.promise(() => fs.writeFile(file, "gone")), + ), + ), + }) }) test("ignores .git/index changes", async () => { diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index 970853bc73..ca73b3336b 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -5,9 +5,7 @@ import { AppFileSystem } from "../../src/filesystem" import { testEffect } from "../lib/effect" import path from "path" -const live = AppFileSystem.layer.pipe( - Layer.provide(NodeFileSystem.layer), -) as Layer.Layer +const live = AppFileSystem.layer.pipe(Layer.provide(NodeFileSystem.layer)) const { effect: it } = testEffect(live) describe("AppFileSystem", () => { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index c66c2239f2..4f8fb90905 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -117,8 +117,10 @@ describe("installation", () => { () => jsonResponse({ versions: { stable: "2.0.0" } }), (cmd, args) => { // getBrewFormula: return core formula (no tap) + // kilocode_change start if (cmd === "brew" && args.includes("--formula") && args.includes("Kilo-Org/tap/kilo")) return "" if (cmd === "brew" && args.includes("--formula") && args.includes("kilo")) return "kilo" + // kilocode_change end return "" }, ) @@ -136,7 +138,9 @@ describe("installation", () => { const layer = testLayer( () => jsonResponse({}), // HTTP not used for tap formula (cmd, args) => { - if (cmd === "brew" && args.includes("Kilo-Org/tap/kilo") && args.includes("--formula")) return "opencode" + // kilocode_change start + if (cmd === "brew" && args.includes("Kilo-Org/tap/kilo") && args.includes("--formula")) return "kilo" + // kilocode_change end if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson return "" }, diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index b967262254..667b7ba9aa 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -54,3 +54,19 @@ describe("plugin.auth-override", () => { expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }, 30000) // Increased timeout for plugin installation }) + +const file = path.join(import.meta.dir, "../../src/plugin/index.ts") + +describe("plugin.config-hook-error-isolation", () => { + test("config hooks are individually error-isolated in the layer factory", async () => { + const src = await Bun.file(file).text() + + // The config hook try/catch lives in the InstanceState factory (layer definition), + // not in init() which now just delegates to the Effect service. + expect(src).toContain("plugin config hook failed") + + const pattern = + /for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/ + expect(pattern.test(src)).toBe(true) + }) +}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f..b030e6cbcd 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,78 +1,69 @@ -import { describe, expect, mock, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" -import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" +import { Effect, Layer, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { AppFileSystem } from "../../src/filesystem" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" Log.init({ print: false }) -const gitModule = await import("../../src/util/git") -const originalGit = gitModule.git +const encoder = new TextEncoder() -type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" -let mode: Mode = "none" - -mock.module("../../src/util/git", () => ({ - git: (args: string[], opts: { cwd: string; env?: Record }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) - }, -})) - -async function withMode(next: Mode, run: () => Promise) { - const prev = mode - mode = next - try { - await run() - } finally { - mode = prev - } +/** + * Creates a mock ChildProcessSpawner layer that intercepts git subcommands + * matching `failArg` and returns exit code 128, while delegating everything + * else to the real CrossSpawnSpawner. + */ +function mockGitFailure(failArg: string) { + return Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const real = yield* ChildProcessSpawner.ChildProcessSpawner + return ChildProcessSpawner.make( + Effect.fnUntraced(function* (command) { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + if (std?.command === "git" && std.args.some((a) => a === failArg)) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: Stream.empty, + stderr: Stream.make(encoder.encode("fatal: simulated failure\n")), + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + }) + } + return yield* real.spawn(command) + }), + ) + }), + ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) } -async function loadProject() { - return (await import("../../src/project/project")).Project +function projectLayerWithFailure(failArg: string) { + return Project.layer.pipe( + Layer.provide(mockGitFailure(failArg)), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodePath.layer), + ) } describe("Project.fromDirectory", () => { test("should handle git repository with no commits", async () => { - const p = await loadProject() await using tmp = await tmpdir() await $`git init`.cwd(tmp.path).quiet() - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) @@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Filesystem.exists(opencodeFile) - expect(fileExists).toBe(false) + expect(await Bun.file(opencodeFile).exists()).toBe(false) }) test("should handle git repository with commits", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) @@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Filesystem.exists(opencodeFile) - expect(fileExists).toBe(true) + expect(await Bun.file(opencodeFile).exists()).toBe(true) }) - test("keeps git vcs when rev-list exits non-zero with empty output", async () => { - const p = await loadProject() + test("returns global for non-git directory", async () => { + await using tmp = await tmpdir() + const { project } = await Project.fromDirectory(tmp.path) + expect(project.id).toBe(ProjectID.global) + }) + + test("derives stable project ID from root commit", async () => { + await using tmp = await tmpdir({ git: true }) + const { project: a } = await Project.fromDirectory(tmp.path) + const { project: b } = await Project.fromDirectory(tmp.path) + expect(b.id).toBe(a.id) + }) +}) + +describe("Project.fromDirectory git failure paths", () => { + test("keeps vcs when rev-list exits non-zero (no commits)", async () => { await using tmp = await tmpdir() await $`git init`.cwd(tmp.path).quiet() - await withMode("rev-list-fail", async () => { - const { project } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) - }) + // rev-list fails because HEAD doesn't exist yet — this is the natural scenario + const { project } = await Project.fromDirectory(tmp.path) + expect(project.vcs).toBe("git") + expect(project.id).toBe(ProjectID.global) + expect(project.worktree).toBe(tmp.path) }) - test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => { - const p = await loadProject() + test("handles show-toplevel failure gracefully", async () => { await using tmp = await tmpdir({ git: true }) + const layer = projectLayerWithFailure("--show-toplevel") - await withMode("top-fail", async () => { - const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = await Effect.runPromise( + Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)), + ) + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) }) - test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => { - const p = await loadProject() + test("handles git-common-dir failure gracefully", async () => { await using tmp = await tmpdir({ git: true }) + const layer = projectLayerWithFailure("--git-common-dir") - await withMode("common-dir-fail", async () => { - const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = await Effect.runPromise( + Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)), + ) + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) }) }) describe("Project.fromDirectory with worktrees", () => { test("should set worktree to root when called from root", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project, sandbox } = await p.fromDirectory(tmp.path) + const { project, sandbox } = await Project.fromDirectory(tmp.path) expect(project.worktree).toBe(tmp.path) expect(sandbox).toBe(tmp.path) @@ -151,14 +149,13 @@ describe("Project.fromDirectory with worktrees", () => { }) test("should set worktree to root when called from a worktree", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") try { await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() - const { project, sandbox } = await p.fromDirectory(worktreePath) + const { project, sandbox } = await Project.fromDirectory(worktreePath) expect(project.worktree).toBe(tmp.path) expect(sandbox).toBe(worktreePath) @@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => { }) test("worktree should share project ID with main repo", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project: main } = await p.fromDirectory(tmp.path) + const { project: main } = await Project.fromDirectory(tmp.path) const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") try { await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() - const { project: wt } = await p.fromDirectory(worktreePath) + const { project: wt } = await Project.fromDirectory(worktreePath) expect(wt.id).toBe(main.id) // Cache should live in the common .git dir, not the worktree's .git file const cache = path.join(tmp.path, ".git", "opencode") - const exists = await Filesystem.exists(cache) + const exists = await Bun.file(cache).exists() expect(exists).toBe(true) } finally { await $`git worktree remove ${worktreePath}` @@ -199,7 +195,6 @@ describe("Project.fromDirectory with worktrees", () => { }) test("separate clones of the same repo should share project ID", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) // Create a bare remote, push, then clone into a second directory @@ -209,8 +204,8 @@ describe("Project.fromDirectory with worktrees", () => { await $`git clone --bare ${tmp.path} ${bare}`.quiet() await $`git clone ${bare} ${clone}`.quiet() - const { project: a } = await p.fromDirectory(tmp.path) - const { project: b } = await p.fromDirectory(clone) + const { project: a } = await Project.fromDirectory(tmp.path) + const { project: b } = await Project.fromDirectory(clone) expect(b.id).toBe(a.id) } finally { @@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => { }) test("should accumulate multiple worktrees in sandboxes", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") @@ -228,8 +222,8 @@ describe("Project.fromDirectory with worktrees", () => { await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() - await p.fromDirectory(worktree1) - const { project } = await p.fromDirectory(worktree2) + await Project.fromDirectory(worktree1) + const { project } = await Project.fromDirectory(worktree2) expect(project.worktree).toBe(tmp.path) expect(project.sandboxes).toContain(worktree1) @@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => { describe("Project.discover", () => { test("should discover favicon.png in root", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await Bun.write(path.join(tmp.path, "favicon.png"), pngData) - await p.discover(project) + await Project.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -268,13 +261,12 @@ describe("Project.discover", () => { }) test("should not discover non-image files", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") - await p.discover(project) + await Project.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -344,8 +336,6 @@ describe("Project.update", () => { }) test("should throw error when project not found", async () => { - await using tmp = await tmpdir({ git: true }) - await expect( Project.update({ projectID: ProjectID.make("nonexistent-project-id"), @@ -358,22 +348,24 @@ describe("Project.update", () => { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) - let eventFired = false let eventPayload: any = null - - GlobalBus.on("event", (data) => { - eventFired = true + const on = (data: any) => { eventPayload = data - }) + } + GlobalBus.on("event", on) - await Project.update({ - projectID: project.id, - name: "Updated Name", - }) + try { + await Project.update({ + projectID: project.id, + name: "Updated Name", + }) - expect(eventFired).toBe(true) - expect(eventPayload.payload.type).toBe("project.updated") - expect(eventPayload.payload.properties.name).toBe("Updated Name") + expect(eventPayload).not.toBeNull() + expect(eventPayload.payload.type).toBe("project.updated") + expect(eventPayload.payload.properties.name).toBe("Updated Name") + } finally { + GlobalBus.off("event", on) + } }) test("should update multiple fields at once", async () => { @@ -393,3 +385,75 @@ describe("Project.update", () => { expect(updated.commands?.start).toBe("make start") }) }) + +describe("Project.list and Project.get", () => { + test("list returns all projects", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const all = Project.list() + expect(all.length).toBeGreaterThan(0) + expect(all.find((p) => p.id === project.id)).toBeDefined() + }) + + test("get returns project by id", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const found = Project.get(project.id) + expect(found).toBeDefined() + expect(found!.id).toBe(project.id) + }) + + test("get returns undefined for unknown id", () => { + const found = Project.get(ProjectID.make("nonexistent")) + expect(found).toBeUndefined() + }) +}) + +describe("Project.setInitialized", () => { + test("sets time_initialized on project", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + expect(project.time.initialized).toBeUndefined() + + Project.setInitialized(project.id) + + const updated = Project.get(project.id) + expect(updated?.time.initialized).toBeDefined() + }) +}) + +describe("Project.addSandbox and Project.removeSandbox", () => { + test("addSandbox adds directory and removeSandbox removes it", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const sandboxDir = path.join(tmp.path, "sandbox-test") + + await Project.addSandbox(project.id, sandboxDir) + + let found = Project.get(project.id) + expect(found?.sandboxes).toContain(sandboxDir) + + await Project.removeSandbox(project.id, sandboxDir) + + found = Project.get(project.id) + expect(found?.sandboxes).not.toContain(sandboxDir) + }) + + test("addSandbox emits GlobalBus event", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const sandboxDir = path.join(tmp.path, "sandbox-event") + + const events: any[] = [] + const on = (evt: any) => events.push(evt) + GlobalBus.on("event", on) + + await Project.addSandbox(project.id, sandboxDir) + + GlobalBus.off("event", on) + expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index a667ac743a..a7a9a5d4c9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1629,6 +1629,43 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile" }) }) +describe("ProviderTransform.message - bedrock caching with non-bedrock providerID", () => { + test("applies cache options at message level when npm package is amazon-bedrock", () => { + const model = { + id: "aws/us.anthropic.claude-opus-4-6-v1", + providerID: "aws", + api: { + id: "us.anthropic.claude-opus-4-6-v1", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Opus 4.6", + capabilities: {}, + options: {}, + headers: {}, + } as any + + const msgs = [ + { + role: "system", + content: [{ type: "text", text: "You are a helpful assistant" }], + }, + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + // Cache should be at the message level and not the content-part level + expect(result[0].providerOptions?.bedrock).toEqual({ + cachePoint: { type: "default" }, + }) + expect(result[0].content[0].providerOptions?.bedrock).toBeUndefined() + }) +}) + describe("ProviderTransform.message - cache control on gateway", () => { const createModel = (overrides: Partial = {}) => ({ diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index ee4c51646f..91e0fd9263 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -117,3 +117,16 @@ describe("session messages endpoint", () => { }) }) }) + +describe("session.prompt_async error handling", () => { + test("prompt_async route has error handler for detached prompt call", async () => { + const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text() + const start = src.indexOf('"/:sessionID/prompt_async"') + const end = src.indexOf('"/:sessionID/command"', start) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + const route = src.slice(start, end) + expect(route).toContain(".catch(") + expect(route).toContain("Bus.publish(Session.Event.Error") + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 58ac9e76f4..c98cce1696 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,6 +1,7 @@ import path from "path" import fs from "fs/promises" import { describe, expect, test } from "bun:test" +import { NamedError } from "@opencode-ai/util/error" import { fileURLToPath } from "url" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -331,3 +332,78 @@ describe("session.prompt abort", () => { }, 15000) }) // kilocode_change end + +describe("session.agent-resolution", () => { + test("unknown agent throws typed error", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(err).not.toBeInstanceOf(TypeError) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') + } + }, + }) + }, 30000) + + test("unknown agent error includes available agent names", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).then( + () => undefined, + (e) => e, + ) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain("build") + } + }, + }) + }, 30000) + + test("unknown command throws typed error with available names", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.command({ + sessionID: session.id, + command: "nonexistent-command-xyz", + arguments: "", + }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(err).not.toBeInstanceOf(TypeError) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') + expect(err.data.message).toContain("init") + } + }, + }) + }, 30000) +}) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 5a2c152c8e..bf54feb472 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1183,342 +1183,6 @@ test("diffFull with whitespace changes", async () => { }) }) -// ── Tests for snapshot optimizations (upstream #17878, #20564) ──────── - -test("batch revert with many files sharing same hash", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - // Create enough files to trigger batching (>1 per hash) - for (let i = 0; i < 20; i++) { - await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) - } - await $`git add .`.cwd(dir).quiet() - await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - // Modify all 20 files - for (let i = 0; i < 20; i++) { - await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) - } - // Add 5 new files - for (let i = 0; i < 5; i++) { - await Filesystem.write(`${tmp.path}/new${i}.txt`, `new-${i}`) - } - - const patch = await Snapshot.patch(before!) - expect(patch.files.length).toBe(25) - - await Snapshot.revert([patch]) - - // Modified files should be restored - for (let i = 0; i < 20; i++) { - const content = await Filesystem.readText(`${tmp.path}/file${i}.txt`) - expect(content).toBe(`original-${i}`) - } - // New files should be deleted - for (let i = 0; i < 5; i++) { - expect( - await fs - .access(`${tmp.path}/new${i}.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - } - }, - }) -}) - -test("batch revert with files in nested subdirectories", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await fs.mkdir(`${dir}/a/b/c`, { recursive: true }) - await fs.mkdir(`${dir}/d/e`, { recursive: true }) - for (let i = 0; i < 10; i++) { - await Filesystem.write(`${dir}/a/b/c/file${i}.txt`, `deep-${i}`) - await Filesystem.write(`${dir}/d/e/file${i}.txt`, `other-${i}`) - } - await $`git add .`.cwd(dir).quiet() - await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - for (let i = 0; i < 10; i++) { - await Filesystem.write(`${tmp.path}/a/b/c/file${i}.txt`, `modified-${i}`) - await Filesystem.write(`${tmp.path}/d/e/file${i}.txt`, `modified-${i}`) - } - - await Snapshot.revert([await Snapshot.patch(before!)]) - - for (let i = 0; i < 10; i++) { - expect(await Filesystem.readText(`${tmp.path}/a/b/c/file${i}.txt`)).toBe(`deep-${i}`) - expect(await Filesystem.readText(`${tmp.path}/d/e/file${i}.txt`)).toBe(`other-${i}`) - } - }, - }) -}) - -test("incremental add skips new files larger than 2MB", async () => { - await using tmp = await bootstrap() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - // Create a file larger than 2MB - const largeContent = "x".repeat(3 * 1024 * 1024) - await Filesystem.write(`${tmp.path}/large.bin`, largeContent) - // Also create a small file - await Filesystem.write(`${tmp.path}/small.txt`, "small change") - - const after = await Snapshot.track() - expect(after).toBeTruthy() - - const patch = await Snapshot.patch(before!) - // Small file should be tracked, large file should be excluded - const files = patch.files.map((f) => path.basename(f)) - expect(files).toContain("small.txt") - expect(files).not.toContain("large.bin") - }, - }) -}) - -test("incremental add: tracked files that grow past 2MB are still visible in patch", async () => { - // The 2MB size filter only prevents NEW large files from being added to the - // snapshot index. Already-tracked files that grow past 2MB still appear in - // patch/diff because those compare against the working tree. This matches - // upstream OpenCode behavior. - await using tmp = await bootstrap() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/growing.txt`, "small") - const before = await Snapshot.track() - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/growing.txt`, "x".repeat(3 * 1024 * 1024)) - await Filesystem.write(`${tmp.path}/a.txt`, "changed") - - const patch = await Snapshot.patch(before!) - const files = patch.files.map((f) => path.basename(f)) - expect(files).toContain("a.txt") - expect(files).toContain("growing.txt") - }, - }) -}) - -test("concurrent patch() calls return consistent results", async () => { - await using tmp = await bootstrap() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/a.txt`, "changed") - - const results = await Promise.all([Snapshot.patch(before!), Snapshot.patch(before!), Snapshot.patch(before!)]) - - // All should report the same changed files - for (const result of results) { - expect(result.files).toContain(fwd(tmp.path, "a.txt")) - } - }, - }) -}) - -test("batch revert with mix of modified, new, and deleted files", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - for (let i = 0; i < 15; i++) { - await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) - } - await $`git add .`.cwd(dir).quiet() - await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - // Modify some files - for (let i = 0; i < 5; i++) { - await Filesystem.write(`${tmp.path}/file${i}.txt`, `modified-${i}`) - } - // Delete some files - for (let i = 5; i < 10; i++) { - await fs.unlink(`${tmp.path}/file${i}.txt`) - } - // Add new files - for (let i = 0; i < 5; i++) { - await Filesystem.write(`${tmp.path}/added${i}.txt`, `added-${i}`) - } - - await Snapshot.revert([await Snapshot.patch(before!)]) - - // Modified files restored - for (let i = 0; i < 5; i++) { - expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) - } - // Deleted files restored - for (let i = 5; i < 10; i++) { - expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) - } - // Untouched files unchanged - for (let i = 10; i < 15; i++) { - expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) - } - // New files removed - for (let i = 0; i < 5; i++) { - expect( - await fs - .access(`${tmp.path}/added${i}.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - } - }, - }) -}) - -test("batch revert with multiple patches from different snapshots", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - for (let i = 0; i < 10; i++) { - await Filesystem.write(`${dir}/file${i}.txt`, `v0-${i}`) - } - await $`git add .`.cwd(dir).quiet() - await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Snapshot A - const snapA = await Snapshot.track() - expect(snapA).toBeTruthy() - - // Modify first 5 files - for (let i = 0; i < 5; i++) { - await Filesystem.write(`${tmp.path}/file${i}.txt`, `v1-${i}`) - } - - // Snapshot B (captures v1 state) - const snapB = await Snapshot.track() - expect(snapB).toBeTruthy() - - // Modify last 5 files - for (let i = 5; i < 10; i++) { - await Filesystem.write(`${tmp.path}/file${i}.txt`, `v2-${i}`) - } - - // Revert with patches from two different hashes - const patchA = await Snapshot.patch(snapA!) - const patchB = await Snapshot.patch(snapB!) - // patchA covers files 0-4 (changed in v1) + files 5-9 (changed in v2) - // patchB covers files 5-9 (changed in v2) - // Reverting [patchA, patchB]: patchA's hash wins for all files (first seen) - await Snapshot.revert([patchA, patchB]) - - // All files should be at v0 (snapA's state) - for (let i = 0; i < 10; i++) { - expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`v0-${i}`) - } - }, - }) -}) - -test("track after revert produces clean snapshot", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - for (let i = 0; i < 10; i++) { - await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) - } - await $`git add .`.cwd(dir).quiet() - await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - for (let i = 0; i < 10; i++) { - await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) - } - - await Snapshot.revert([await Snapshot.patch(before!)]) - - // After revert, a new track should match the original snapshot - const after = await Snapshot.track() - expect(after).toBeTruthy() - expect(after).toBe(before) - }, - }) -}) - -test("incremental add tracks newly created files", async () => { - await using tmp = await bootstrap() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - // Create several new files in subdirectories - await fs.mkdir(`${tmp.path}/newdir/sub`, { recursive: true }) - await Filesystem.write(`${tmp.path}/newdir/one.txt`, "one") - await Filesystem.write(`${tmp.path}/newdir/sub/two.txt`, "two") - await Filesystem.write(`${tmp.path}/three.txt`, "three") - - const patch = await Snapshot.patch(before!) - const files = patch.files.map((f) => path.basename(f)) - expect(files).toContain("one.txt") - expect(files).toContain("two.txt") - expect(files).toContain("three.txt") - }, - }) -}) - -test("incremental add tracks modified and deleted files", async () => { - await using tmp = await bootstrap() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await Snapshot.track() - expect(before).toBeTruthy() - - // Modify one file, delete the other - await Filesystem.write(`${tmp.path}/a.txt`, "modified-a") - await fs.unlink(`${tmp.path}/b.txt`) - - const patch = await Snapshot.patch(before!) - const files = patch.files.map((f) => path.basename(f)) - expect(files).toContain("a.txt") - expect(files).toContain("b.txt") - }, - }) -}) - test("revert with overlapping files across patches uses first patch hash", async () => { await using tmp = await bootstrap() await Instance.provide({ diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index 407b89d61e..3791d344db 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -1,11 +1,19 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Global } from "../../src/global" +import { Installation } from "../../src/installation" import { Database } from "../../src/storage/db" describe("Database.Path", () => { - // kilocode_change - always use kilo.db regardless of channel - test("always uses kilo.db", () => { - const file = path.basename(Database.Path) - expect(file).toBe("kilo.db") + test("returns database path for the current channel", () => { + const db = process.env["KILO_DB"] + const expected = db + ? path.isAbsolute(db) + ? db + : path.join(Global.Path.data, db) + : ["latest", "beta"].includes(Installation.CHANNEL) + ? path.join(Global.Path.data, "kilo.db") // kilcode_change + : path.join(Global.Path.data, `kilo-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) // kilocode_change + expect(Database.Path).toBe(expected) }) }) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index ba3d5fb16f..69608b37d4 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -75,6 +75,56 @@ name: ${name} description: ${description} --- +# ${name} +`, + ) + } + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const first = await SkillTool.init() + const second = await SkillTool.init() + + expect(first.description).toBe(second.description) + + const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.") + const middle = first.description.indexOf("**middle-skill**: Middle skill.") + const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.") + + expect(alpha).toBeGreaterThan(-1) + expect(middle).toBeGreaterThan(alpha) + expect(zeta).toBeGreaterThan(middle) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } + }) + + test("description sorts skills by name and is stable across calls", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (const [name, description] of [ + ["zeta-skill", "Zeta skill."], + ["alpha-skill", "Alpha skill."], + ["middle-skill", "Middle skill."], + ]) { + const skillDir = path.join(dir, ".opencode", "skill", name) + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: ${name} +description: ${description} +--- + # ${name} `, ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 632b2c0138..0c5ea9901e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4366,6 +4366,38 @@ export class File extends HeyApiClient { } } +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + export class Auth2 extends HeyApiClient { /** * Remove MCP OAuth @@ -5387,38 +5419,6 @@ export class Formatter extends HeyApiClient { } } -export class Event extends HeyApiClient { - /** - * Subscribe to events - * - * Get events - */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - export class KiloClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -5537,6 +5537,11 @@ export class KiloClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) + } + private _mcp?: Mcp get mcp(): Mcp { return (this._mcp ??= new Mcp({ client: this.client })) @@ -5581,9 +5586,4 @@ export class KiloClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } - - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c848e83ae7..895dfebbba 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1053,6 +1053,21 @@ export type Event = | EventLspClientDiagnostics | EventLspUpdated | EventFileEdited + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartDelta + | EventMessagePartRemoved + | EventPermissionAsked + | EventPermissionReplied + | EventSessionStatus + | EventSessionIdle + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventSessionCompacted + | EventFileWatcherUpdated + | EventTodoUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -5651,6 +5666,25 @@ export type FileStatusResponses = { export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type EventSubscribeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/event" +} + +export type EventSubscribeResponses = { + /** + * Event stream + */ + 200: Event +} + +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] + export type McpStatusData = { body?: never path?: never @@ -6401,22 +6435,3 @@ export type FormatterStatusResponses = { } export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] - -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f2240cf147..51fec1bf96 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -308,6 +308,82 @@ ] } }, + "/global/upgrade": { + "post": { + "operationId": "global.upgrade", + "summary": "Upgrade opencode", + "description": "Upgrade opencode to the specified version or latest if not specified.", + "responses": { + "200": { + "description": "Upgrade result", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "version": { + "type": "string" + } + }, + "required": ["success", "version"] + }, + { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "string" + } + }, + "required": ["success", "error"] + } + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "string" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.upgrade({\n ...\n})" + } + ] + } + }, "/auth/{providerID}": { "put": { "operationId": "auth.set", diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index 8744655422..f75240b9c4 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -8,7 +8,7 @@ const dict: Record = { "prompt.placeholder.shell": "Run a shell command...", "prompt.placeholder.summarizeComment": "Summarize this comment", "prompt.placeholder.summarizeComments": "Summarize these comments", - "prompt.action.attachFile": "Attach file", + "prompt.action.attachFile": "Attach files", "prompt.action.send": "Send", "prompt.action.stop": "Stop", "prompt.attachment.remove": "Remove attachment", diff --git a/packages/ui/package.json b/packages/ui/package.json index d126a779a7..515e862697 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,7 @@ "./hooks": "./src/hooks/index.ts", "./context": "./src/context/index.ts", "./context/*": "./src/context/*.tsx", + "./font-loader": "./src/font-loader.ts", "./styles": "./src/styles/index.css", "./styles/tailwind": "./src/styles/tailwind/index.css", "./theme": "./src/theme/index.ts", diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 9b7db1e19d..028972fec1 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -8,7 +8,10 @@ justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { + flex: 0 1 auto; width: auto; + max-width: calc(100% - 24px); + min-width: 0; display: flex; align-items: center; align-self: stretch; @@ -51,12 +54,16 @@ [data-slot="basic-tool-tool-info"] { flex: 0 1 auto; min-width: 0; + max-width: 100%; font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { + flex: 0 1 auto; width: auto; - display: flex; + max-width: 100%; + min-width: 0; + display: inline-flex; align-items: center; gap: 8px; justify-content: flex-start; @@ -152,4 +159,10 @@ letter-spacing: var(--letter-spacing-normal); color: var(--text-base); } + + [data-slot="basic-tool-tool-action"] { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } } diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 7aa0262071..97cc02aab0 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -180,7 +180,9 @@ export function BasicTool(props: BasicToolProps) { - {trigger().action} + + {trigger().action} + )} diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index f735747a49..e1a508f16a 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,121 +1,9 @@ +import { Link, Style } from "@solidjs/meta" import { Show } from "solid-js" -import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" -import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" - -import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" -import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" -import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" -import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" -import hack from "../assets/fonts/hack-nerd-font.woff2" -import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" -import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" -import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" -import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" -import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" -import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" -import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" -import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" -import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" -import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" -import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" -import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" -import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" -import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" -import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" -import iosevka from "../assets/fonts/iosevka-nerd-font.woff2" -import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2" -import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2" -import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2" - -type MonoFont = { - family: string - regular: string - bold: string -} - -export const MONO_NERD_FONTS = [ - { - family: "JetBrains Mono Nerd Font", - regular: jetbrainsMono, - bold: jetbrainsMonoBold, - }, - { - family: "Fira Code Nerd Font", - regular: firaCode, - bold: firaCodeBold, - }, - { - family: "Cascadia Code Nerd Font", - regular: cascadiaCode, - bold: cascadiaCodeBold, - }, - { - family: "Hack Nerd Font", - regular: hack, - bold: hackBold, - }, - { - family: "Source Code Pro Nerd Font", - regular: sourceCodePro, - bold: sourceCodeProBold, - }, - { - family: "Inconsolata Nerd Font", - regular: inconsolata, - bold: inconsolataBold, - }, - { - family: "Roboto Mono Nerd Font", - regular: robotoMono, - bold: robotoMonoBold, - }, - { - family: "Ubuntu Mono Nerd Font", - regular: ubuntuMono, - bold: ubuntuMonoBold, - }, - { - family: "Intel One Mono Nerd Font", - regular: intelOneMono, - bold: intelOneMonoBold, - }, - { - family: "Meslo LGS Nerd Font", - regular: mesloLgs, - bold: mesloLgsBold, - }, - { - family: "Iosevka Nerd Font", - regular: iosevka, - bold: iosevkaBold, - }, - { - family: "GeistMono Nerd Font", - regular: geistMono, - bold: geistMonoBold, - }, -] satisfies MonoFont[] - -const monoNerdCss = MONO_NERD_FONTS.map( - (font) => ` - @font-face { - font-family: "${font.family}"; - src: url("${font.regular}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "${font.family}"; - src: url("${font.bold}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; - }`, -).join("") +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" export const Font = () => { return ( @@ -165,7 +53,6 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } -${monoNerdCss} `} diff --git a/packages/ui/src/components/hover-card.tsx b/packages/ui/src/components/hover-card.tsx index 210fd54160..8330375aa3 100644 --- a/packages/ui/src/components/hover-card.tsx +++ b/packages/ui/src/components/hover-card.tsx @@ -13,7 +13,7 @@ export function HoverCard(props: HoverCardProps) { return ( - + {local.trigger} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9ed0dc125b..f4582e4f2a 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -431,7 +431,7 @@ display: flex; align-items: center; justify-content: space-between; - gap: 8px; + gap: 12px; width: 100%; [data-slot="message-part-title-area"] { @@ -443,10 +443,11 @@ } [data-slot="message-part-title"] { - flex-shrink: 0; + flex: 1 1 auto; display: flex; align-items: center; gap: 8px; + min-width: 0; font-family: var(--font-family-sans); font-size: 14px; font-style: normal; @@ -473,12 +474,17 @@ } [data-slot="message-part-title-text"] { + flex-shrink: 0; text-transform: capitalize; color: var(--text-strong); } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-weight: var(--font-weight-regular); /* kilocode_change start */ @@ -544,6 +550,7 @@ gap: 16px; align-items: center; justify-content: flex-end; + flex-shrink: 0; } } @@ -1234,6 +1241,7 @@ display: flex; flex-grow: 1; min-width: 0; + overflow: hidden; } [data-slot="apply-patch-directory"] { @@ -1247,7 +1255,11 @@ [data-slot="apply-patch-filename"] { color: var(--text-strong); - flex-shrink: 0; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } [data-slot="apply-patch-trigger-actions"] { diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index 2e49e18607..a10b9db72b 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -181,20 +181,6 @@ fill="currentColor" > - - - - - - - - - - - - - - list(data.store.message?.[props.sessionID], emptyMessages)) + const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages @@ -353,30 +354,28 @@ export function SessionTurn( if (end < start) return undefined return end - start }) - const assistantVisible = createMemo(() => - assistantMessages().reduce((count, message) => { - const parts = list(data.store.part?.[message.id], emptyParts) - return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length - }, 0), - ) - const assistantTailVisible = createMemo(() => - assistantMessages() - .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) - .flatMap((part) => { - if (partState(part, showReasoningSummaries()) !== "visible") return [] - if (part.type === "text") return ["text" as const] - return ["other" as const] - }) - .at(-1), - ) - const reasoningHeading = createMemo(() => - assistantMessages() - .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) - .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning") - .map((part) => heading(part.text)) - .filter((text): text is string => !!text) - .at(-1), - ) + const assistantDerived = createMemo(() => { + let visible = 0 + let tail: "text" | "other" | undefined + let reason: string | undefined + const show = showReasoningSummaries() + for (const message of assistantMessages()) { + for (const part of list(data.store.part?.[message.id], emptyParts)) { + if (partState(part, show) === "visible") { + visible++ + tail = part.type === "text" ? "text" : "other" + } + if (part.type === "reasoning" && part.text) { + const h = heading(part.text) + if (h) reason = h + } + } + } + return { visible, tail, reason } + }) + const assistantVisible = createMemo(() => assistantDerived().visible) + const assistantTailVisible = createMemo(() => assistantDerived().tail) + const reasoningHeading = createMemo(() => assistantDerived().reason) const showThinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false // kilocode_change diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts new file mode 100644 index 0000000000..f2b1e6be13 --- /dev/null +++ b/packages/ui/src/font-loader.ts @@ -0,0 +1,133 @@ +type MonoFont = { + id: string + family: string + regular: string + bold: string +} + +let files: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> + return files +} + +export const MONO_NERD_FONTS = [ + { + id: "jetbrains-mono", + family: "JetBrains Mono Nerd Font", + regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", + bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", + }, + { + id: "fira-code", + family: "Fira Code Nerd Font", + regular: "./assets/fonts/fira-code-nerd-font.woff2", + bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", + }, + { + id: "cascadia-code", + family: "Cascadia Code Nerd Font", + regular: "./assets/fonts/cascadia-code-nerd-font.woff2", + bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", + }, + { + id: "hack", + family: "Hack Nerd Font", + regular: "./assets/fonts/hack-nerd-font.woff2", + bold: "./assets/fonts/hack-nerd-font-bold.woff2", + }, + { + id: "source-code-pro", + family: "Source Code Pro Nerd Font", + regular: "./assets/fonts/source-code-pro-nerd-font.woff2", + bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", + }, + { + id: "inconsolata", + family: "Inconsolata Nerd Font", + regular: "./assets/fonts/inconsolata-nerd-font.woff2", + bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", + }, + { + id: "roboto-mono", + family: "Roboto Mono Nerd Font", + regular: "./assets/fonts/roboto-mono-nerd-font.woff2", + bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", + }, + { + id: "ubuntu-mono", + family: "Ubuntu Mono Nerd Font", + regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", + bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", + }, + { + id: "intel-one-mono", + family: "Intel One Mono Nerd Font", + regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", + bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", + }, + { + id: "meslo-lgs", + family: "Meslo LGS Nerd Font", + regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", + bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", + }, + { + id: "iosevka", + family: "Iosevka Nerd Font", + regular: "./assets/fonts/iosevka-nerd-font.woff2", + bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", + }, + { + id: "geist-mono", + family: "GeistMono Nerd Font", + regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", + bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", + }, +] satisfies MonoFont[] + +const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record +const loads = new Map>() + +function css(font: { family: string; regular: string; bold: string }) { + return ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + } + ` +} + +export function ensureMonoFont(id: string | undefined) { + if (!id || id === "ibm-plex-mono") return Promise.resolve() + if (typeof document !== "object") return Promise.resolve() + const font = mono[id] + if (!font) return Promise.resolve() + const styleId = `oc-font-${font.id}` + if (document.getElementById(styleId)) return Promise.resolve() + const hit = loads.get(font.id) + if (hit) return hit + const files = getFiles() + const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { + if (!regular || !bold) return + if (document.getElementById(styleId)) return + const style = document.createElement("style") + style.id = styleId + style.textContent = css({ family: font.family, regular, bold }) + document.head.appendChild(style) + }) + loads.set(font.id, load) + return load +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 9808c8e841..7d25ac3972 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { DEFAULT_THEMES } from "./default-themes" +import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,14 +15,101 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" +let files: Record Promise<{ default: DesktopTheme }>> | undefined +let ids: string[] | undefined +let known: Set | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") + return files +} + +function themeIDs() { + if (ids) return ids + ids = Object.keys(getFiles()) + .map((path) => path.slice("./themes/".length, -".json".length)) + .sort() + return ids +} + +function knownThemes() { + if (known) return known + known = new Set(themeIDs()) + return known +} + +const names: Record = { + "oc-2": "OC-2", + amoled: "AMOLED", + aura: "Aura", + ayu: "Ayu", + carbonfox: "Carbonfox", + catppuccin: "Catppuccin", + "catppuccin-frappe": "Catppuccin Frappe", + "catppuccin-macchiato": "Catppuccin Macchiato", + cobalt2: "Cobalt2", + cursor: "Cursor", + dracula: "Dracula", + everforest: "Everforest", + flexoki: "Flexoki", + github: "GitHub", + gruvbox: "Gruvbox", + kanagawa: "Kanagawa", + "lucent-orng": "Lucent Orng", + material: "Material", + matrix: "Matrix", + mercury: "Mercury", + monokai: "Monokai", + nightowl: "Night Owl", + nord: "Nord", + "one-dark": "One Dark", + onedarkpro: "One Dark Pro", + opencode: "OpenCode", + orng: "Orng", + "osaka-jade": "Osaka Jade", + palenight: "Palenight", + rosepine: "Rose Pine", + shadesofpurple: "Shades of Purple", + solarized: "Solarized", + synthwave84: "Synthwave '84", + tokyonight: "Tokyonight", + vercel: "Vercel", + vesper: "Vesper", + zenburn: "Zenburn", +} +const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } +function read(key: string) { + if (typeof localStorage !== "object") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function write(key: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(key, value) + } catch {} +} + +function drop(key: string) { + if (typeof localStorage !== "object") return + try { + localStorage.removeItem(key) + } catch {} +} + function clear() { - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) + drop(STORAGE_KEYS.THEME_CSS_LIGHT) + drop(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { + if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } const fullCss = `:root { @@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: DEFAULT_THEMES as Record, - themeId: normalize(props.defaultTheme) ?? "oc-2", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themes: { + "oc-2": oc2Theme, + } as Record, + themeId, + colorScheme, + mode, previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) - window.addEventListener("storage", (e) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) - } - }) + const loads = new Map>() - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - if (store.colorScheme === "system") { - setStore("mode", getSystemMode()) - } - } - mediaQuery.addEventListener("change", handler) - onCleanup(() => mediaQuery.removeEventListener("change", handler)) - - const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const themeId = normalize(savedTheme) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (themeId && store.themes[themeId]) { - setStore("themeId", themeId) - } - if (savedTheme && themeId && savedTheme !== themeId) { - localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) - clear() - } - if (savedScheme) { - setStore("colorScheme", savedScheme) - if (savedScheme !== "system") { - setStore("mode", savedScheme) - } - } - const currentTheme = store.themes[store.themeId] - if (currentTheme) { - cacheThemeVariants(currentTheme, store.themeId) - } - }) + const load = (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme + }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } + const ids = () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } + + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + setStore("themeId", next) + if (next === "oc-2") { + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + }) + } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } + + if (typeof window === "object") { + window.addEventListener("storage", onStorage) + onCleanup(() => window.removeEventListener("storage", onStorage)) + } + + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + mediaQuery.addEventListener("change", onMedia) + onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) + }) + createEffect(() => { const theme = store.themes[store.themeId] - if (theme) { - applyTheme(theme, store.themeId, store.mode) - } + if (!theme) return + applyTheme(theme, store.themeId, store.mode) }) const setTheme = (id: string) => { @@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - const theme = store.themes[next] - if (!theme) { + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) - localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) clear() return } - cacheThemeVariants(theme, next) + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, + ids, + name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, + loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - const theme = store.themes[next] - if (!theme) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next) - const previewMode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, previewMode) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode) + }) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const previewMode = scheme === "system" ? getSystemMode() : scheme + const mode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - const theme = store.themes[id] - if (theme) { - applyTheme(theme, id, previewMode) - } + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode) + }) }, commitPreview: () => { if (store.previewThemeId) { @@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - const theme = store.themes[store.themeId] - if (theme) { + void load(store.themeId).then((theme) => { + if (!theme) return applyTheme(theme, store.themeId, store.mode) - } + }) }, } }, diff --git a/upstream-merge-report-1.3.1.md b/upstream-merge-report-1.3.1.md new file mode 100644 index 0000000000..d4f5bad364 --- /dev/null +++ b/upstream-merge-report-1.3.1.md @@ -0,0 +1,973 @@ +# Upstream Merge Conflict Report + +Generated: 2026-04-11T14:25:12.736Z + +## Summary + +- **Upstream Version**: 1.3.1 +- **Upstream Commit**: `ec64ceab` +- **Base Branch**: main +- **Merge Branch**: catrielmuller/kilo-opencode-v1.3.1 +- **Total Files Changed**: 463 + +## Files by Recommendation + +### Skip (Auto-Remove) + +- `.github/workflows/deploy.yml` (config) + - File should be skipped (does not exist in Kilo fork) +- `.github/workflows/vouch-manage-by-issue.yml` (config) + - File should be skipped (does not exist in Kilo fork) +- `README.zh.md` (markdown) + - File should be skipped (does not exist in Kilo fork) +- `README.zht.md` (markdown) + - File should be skipped (does not exist in Kilo fork) +- `packages/console/core/sst-env.d.ts` (code) + - File should be skipped (does not exist in Kilo fork) +- `packages/console/function/sst-env.d.ts` (code) + - File should be skipped (does not exist in Kilo fork) +- `packages/console/resource/sst-env.d.ts` (code) + - File should be skipped (does not exist in Kilo fork) +- `packages/enterprise/sst-env.d.ts` (code) + - File should be skipped (does not exist in Kilo fork) +- `packages/function/sst-env.d.ts` (code) + - File should be skipped (does not exist in Kilo fork) +- `sst-env.d.ts` (code) + - File should be skipped (does not exist in Kilo fork) + +### i18n Transform (Auto-Apply Kilo Branding) + +- `packages/app/src/i18n/ar.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/br.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/bs.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/da.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/de.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/en.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/es.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/fr.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/ja.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/ko.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/no.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/pl.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/ru.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/th.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/tr.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/zh.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding +- `packages/app/src/i18n/zht.ts` (i18n) + - i18n file: take upstream translations and apply Kilo branding + +### Take Upstream + Kilo Branding (Auto) + +- `packages/app/e2e/app/home.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/app/palette.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/prompt/prompt-history.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/prompt/prompt-multiline.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/prompt/prompt-slash-terminal.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/session/session-model-persistence.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/session/session-review.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/settings/settings-keybinds.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/terminal/terminal-init.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/e2e/terminal/terminal-tabs.spec.ts` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/dialog-custom-provider.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/dialog-edit-project.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/dialog-select-mcp.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/dialog-select-server.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/session/session-context-tab.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/session/session-header.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/session/session-sortable-terminal-tab.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/settings-general.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/status-popover.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/terminal.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/components/titlebar.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/command.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/global-sync.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/local.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/notification.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/platform.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/prompt.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/settings.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/sync.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/context/terminal.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/directory-layout.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/error.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/home.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/layout.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/layout/sidebar-items.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/layout/sidebar-project.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/layout/sidebar-workspace.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/session/file-tabs.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/session/message-timeline.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/session/session-side-panel.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/session/terminal-panel.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/app/src/pages/session/use-session-commands.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/ui/src/components/font.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/ui/src/components/hover-card.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/ui/src/components/line-comment.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms +- `packages/ui/src/theme/context.tsx` (code) + - Branding-only file: take upstream and apply Kilo branding transforms + +### Package.json Transform (Auto) + +- `package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/app/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/console/app/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/console/core/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/console/function/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/console/mail/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/desktop-electron/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/desktop/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/enterprise/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/function/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/opencode/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/plugin/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/sdk/js/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/slack/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/ui/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/util/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `packages/web/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version +- `sdks/vscode/package.json` (package) + - Package.json: take upstream, transform names, inject Kilo deps, preserve version + +### Script Transform (Auto) + +- `packages/console/core/script/lookup-user.ts` (script) + - Script file: take upstream and transform GitHub references +- `packages/opencode/script/build-node.ts` (script) + - Script file: take upstream and transform GitHub references + +### Extension Transform (Auto) + +- `packages/extensions/zed/extension.toml` (extension) + - Extension file: take upstream and apply Kilo branding + +### Web/Docs Transform (Auto) + +- `packages/web/src/content/docs/agents.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ar/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ar/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ar/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/bs/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/bs/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/bs/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/config.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/da/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/da/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/da/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/de/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/de/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/de/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/es/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/es/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/es/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/fr/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/fr/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/fr/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/it/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/it/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/it/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/it/zen.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ja/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ja/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ja/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ko/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ko/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ko/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/nb/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/nb/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/nb/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/pl/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/pl/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/pl/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/pt-br/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/pt-br/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/pt-br/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ru/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ru/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/ru/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/th/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/th/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/th/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/tr/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/tr/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/tr/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zen.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zh-cn/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zh-cn/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zh-cn/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zh-tw/cli.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zh-tw/go.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding +- `packages/web/src/content/docs/zh-tw/providers.mdx` (web) + - Web/docs file: take upstream and apply Kilo branding + +### Keep Kilo Version (Ours) + +- `.opencode/agent/translator.md` (markdown) + - Markdown files are typically Kilo-specific documentation +- `.opencode/command/changelog.md` (markdown) + - Markdown files are typically Kilo-specific documentation +- `packages/app/e2e/AGENTS.md` (markdown) + - File is Kilo-specific and should not be overwritten +- `packages/opencode/AGENTS.md` (markdown) + - File is Kilo-specific and should not be overwritten +- `packages/opencode/specs/effect-migration.md` (markdown) + - Markdown files are typically Kilo-specific documentation +- `packages/sdk/openapi.json` (config) + - File is Kilo-specific and should not be overwritten + +### Manual Review Required + +- `.github/VOUCHED.td` (other) + - File needs manual review +- `.github/actions/setup-bun/action.yml` (config) + - Config files may have Kilo-specific settings +- `.github/workflows/nix-hashes.yml` (config) + - Config files may have Kilo-specific settings +- `.github/workflows/test.yml` (config) + - Config files may have Kilo-specific settings +- `.opencode/.gitignore` (other) + - File needs manual review +- `.opencode/tool/github-pr-search.ts` (code) + - Code files need manual review for kilocode_change markers +- `.opencode/tool/github-pr-search.txt` (other) + - File needs manual review +- `.opencode/tool/github-triage.ts` (code) + - Code files need manual review for kilocode_change markers +- `.opencode/tool/github-triage.txt` (other) + - File needs manual review +- `bun.lock` (other) + - File needs manual review +- `flake.lock` (other) + - File needs manual review +- `infra/console.ts` (code) + - Code files need manual review for kilocode_change markers +- `nix/hashes.json` (config) + - Config files may have Kilo-specific settings +- `packages/app/e2e/actions.ts` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/e2e/fixtures.ts` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/e2e/projects/projects-switch.spec.ts` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/e2e/projects/workspace-new-session.spec.ts` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/e2e/projects/workspaces.spec.ts` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/src/app.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/dialog-connect-provider.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/src/components/dialog-custom-provider-form.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/dialog-custom-provider.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/prompt-input.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/src/components/prompt-input/attachments.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/prompt-input/build-request-parts.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/prompt-input/files.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/prompt-input/history.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/components/prompt-input/history.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/constants/file-picker.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/context/command-keybind.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/context/global-sync/bootstrap.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/context/global-sync/child-store.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/context/global-sync/event-reducer.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/context/language.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/src/context/terminal-title.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/entry.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/hooks/use-providers.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/pages/layout/helpers.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/pages/layout/helpers.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/pages/session.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/src/pages/session/composer/session-question-dock.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/app/src/pages/session/helpers.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/pages/session/helpers.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/pages/session/use-session-hash-scroll.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/testing/prompt.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/testing/terminal.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/utils/persist.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/utils/prompt.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/utils/server-health.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/src/utils/sound.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/app/vite.js` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/component/icon.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/component/modal.css` (other) + - File needs manual review +- `packages/console/app/src/i18n/ar.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/br.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/da.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/de.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/en.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/es.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/fr.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/it.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/ja.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/ko.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/no.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/pl.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/ru.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/th.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/tr.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/zh.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/i18n/zht.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/lib/salesforce.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/api/enterprise.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/enterprise/index.css` (other) + - File needs manual review +- `packages/console/app/src/routes/enterprise/index.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/feishu.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/go/index.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/stripe/webhook.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css` (other) + - File needs manual review +- `packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/workspace/[id]/model-section.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/handler.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/provider/anthropic.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/provider/google.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/provider/openai-compatible.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/provider/openai.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/provider/provider.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/app/src/routes/zen/util/trialLimiter.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/core/src/billing.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/core/src/lite.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/core/src/model.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/console/core/src/schema/billing.sql.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/containers/bun-node/Dockerfile` (other) + - File needs manual review +- `packages/desktop-electron/src/main/cli.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/desktop-electron/src/main/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/desktop-electron/src/main/ipc.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/desktop-electron/src/preload/types.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/desktop-electron/src/renderer/index.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/desktop-electron/src/renderer/updater.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/desktop/src/index.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/git` (other) + - File needs manual review +- `packages/opencode/parsers-config.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/script/build.ts` (script) + - Script file has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/opencode/script/seed-e2e.ts` (script) + - Script file has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/opencode/src/account/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/account/repo.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/account/schema.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/account/service.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/agent/agent.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/auth/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/auth/service.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/bus/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/account.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/debug/agent.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/models.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/pr.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/providers.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/run.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/app.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/component/prompt/part.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/context/exit.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/context/theme.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/ui/dialog.tsx` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/util/editor.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/tui/worker.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/cmd/upgrade.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/cli/upgrade.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/command/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/config/config.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/control-plane/workspace.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/effect/cross-spawn-spawner.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/effect/instance-context.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/effect/instance-registry.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/effect/instance-state.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/effect/run-service.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/effect/runtime.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/file/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/file/time.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/file/watcher.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/filesystem/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/flag/flag.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/format/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/global/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/ide/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/installation/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/lsp/client.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/lsp/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/lsp/launch.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/lsp/server.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/mcp/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/node.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/permission/evaluate.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/permission/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/permission/next.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/permission/service.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/plugin/codex.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/plugin/copilot.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/plugin/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/project/bootstrap.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/project/instance.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/project/project.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/project/vcs.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/provider/auth-service.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/provider/auth.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/provider/error.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/provider/provider.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/provider/schema.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/provider/transform.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/pty/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/question/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/question/service.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/scheduler/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/event.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/global.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/permission.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/project.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/provider.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/pty.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/routes/session.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/server/server.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/compaction.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/llm.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/message-v2.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/processor.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/prompt.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/prompt/anthropic-20250930.txt` (other) + - File needs manual review +- `packages/opencode/src/session/prompt/codex.txt` (other) + - File needs manual review +- `packages/opencode/src/session/prompt/default.txt` (other) + - File needs manual review +- `packages/opencode/src/session/session.sql.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/status.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/summary.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/session/system.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/share/share-next.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/skill/discovery.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/skill/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/skill/skill.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/snapshot/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/storage/db.bun.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/storage/db.node.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/storage/db.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/bash.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/edit.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/read.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/registry.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/task.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/tool.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/truncate.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/truncation-dir.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/truncation.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/tool/write.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/util/effect-zod.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/util/eventloop.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/util/fn.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/util/instance-state.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/util/process.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/src/util/which.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/account/repo.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/account/service.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/agent/agent.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/cli/account.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/cli/cmd/tui/prompt-part.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/config/config.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/effect/cross-spawn-spawner.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/effect/instance-state.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/effect/run-service.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/file/index.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/file/time.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/file/watcher.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/filesystem/filesystem.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/fixture/effect.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/fixture/instance.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/format/format.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/installation/installation.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/lib/effect.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/lib/filesystem.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/lsp/launch.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/permission-task.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/permission/next.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/plugin/auth-override.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/preload.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/project/project.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/project/vcs.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/provider/amazon-bedrock.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/provider/auth.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/provider/gitlab-duo.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/provider/provider.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/provider/transform.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/pty/pty-session.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/question/question.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/scheduler.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/server/project-init-git.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/server/session-messages.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/session/llm.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/session/message-v2.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/session/prompt.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/session/system.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/share/share-next.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/skill/discovery.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/skill/skill.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/snapshot/snapshot.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/storage/db.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/bash.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/edit.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/external-directory.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/fixtures/models-api.json` (config) + - Config files may have Kilo-specific settings +- `packages/opencode/test/tool/read.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/registry.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/skill.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/task.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/truncation.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/tool/write.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/util/instance-state.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/opencode/test/util/process.test.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/plugin/src/index.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/sdk/js/src/v2/gen/sdk.gen.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/sdk/js/src/v2/gen/types.gen.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/storybook/.storybook/mocks/app/context/language.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/clarifai.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/dinference.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/drun.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/perplexity-agent.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/tencent-coding-plan.svg` (other) + - File needs manual review +- `packages/ui/src/assets/icons/provider/zenmux.svg` (other) + - File needs manual review +- `packages/ui/src/components/basic-tool.css` (other) + - File needs manual review +- `packages/ui/src/components/basic-tool.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/ui/src/components/line-comment-styles.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/ui/src/components/message-part.css` (other) + - File needs manual review +- `packages/ui/src/components/session-turn.tsx` (code) + - File has kilocode_change markers — auto-transform skipped, needs manual review +- `packages/ui/src/font-loader.ts` (code) + - Code files need manual review for kilocode_change markers +- `packages/ui/src/theme/themes/oc-2.json` (config) + - Config files may have Kilo-specific settings +- `patches/@ai-sdk%2Fxai@2.0.51.patch` (other) + - File needs manual review +- `patches/solid-js@1.9.10.patch` (other) + - File needs manual review + +## Recommendations + +- 10 files will be skipped (auto-removed) +- 17 i18n files will be auto-transformed +- 6 files will keep Kilo's version +- 304 files require manual review