diff --git a/.opencode/tool/github-pr-search.txt b/.opencode/tool/github-pr-search.txt index 28d8643f13c..1b658e71c43 100644 --- a/.opencode/tool/github-pr-search.txt +++ b/.opencode/tool/github-pr-search.txt @@ -1,6 +1,6 @@ Use this tool to search GitHub pull requests by title and description. -This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including: +This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including: - PR number and title - Author - State (open/closed/merged) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed0cb83d8af..59571d307f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,26 @@ kilo serve # Start headless API server kilo web # Start server + open web interface ``` +### Testing with a local backend + +To point the CLI at a local backend (e.g., a locally running Kilo API server on port 3000), set the `KILO_API_URL` environment variable: + +```bash +KILO_API_URL=http://localhost:3000 bun dev +``` + +This redirects all gateway traffic (auth, model listing, provider routing, profile, etc.) to your local server. The default is `https://api.kilo.ai`. + +There are also optional overrides for other services: + +| Variable | Default | Purpose | +| ------------------------- | -------------------------------- | ----------------------------------------- | +| `KILO_API_URL` | `https://api.kilo.ai` | Kilo API (gateway, auth, models, profile) | +| `KILO_SESSION_INGEST_URL` | `https://ingest.kilosessions.ai` | Session export / cloud sync | +| `KILO_MODELS_URL` | `https://models.dev` | Model metadata | + +> **VS Code:** The repo includes a "VSCode - Run Extension (Local Backend)" launch config in `.vscode/launch.json` that sets `KILO_API_URL=http://localhost:3000` automatically. + ### Pull Request Expectations - **Issue First Policy:** All PRs must reference an existing issue. diff --git a/RELEASING.md b/RELEASING.md index ee2e8b465f8..437d34b1926 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,7 +34,7 @@ The `publish.yml` workflow runs four jobs sequentially: - Builds native binaries for **all supported platforms and architectures**: - Linux: x64, arm64 (glibc and musl), plus baseline (non-AVX2) variants - macOS: x64, arm64, plus baseline variants - - Windows: x64, plus baseline variant + - Windows: x64 (plus baseline variant), arm64 - Patches ELF interpreters on Linux binaries for broad compatibility. - Creates platform archives (`.tar.gz` for Linux, `.zip` for macOS/Windows) and uploads them to the draft GitHub Release. - Uploads the `dist/` directory as a workflow artifact (`kilo-cli`) for subsequent jobs. @@ -43,7 +43,7 @@ The `publish.yml` workflow runs four jobs sequentially: - Downloads the CLI artifacts from the previous job. - Runs `packages/kilo-vscode/script/build.ts` to build VSIX packages for all target platforms: - - `linux-x64`, `linux-arm64`, `alpine-x64`, `alpine-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64` + - `linux-x64`, `linux-arm64`, `alpine-x64`, `alpine-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`, `win32-arm64` - Each VSIX bundles the platform-specific CLI binary. - Uploads the VSIX files as a workflow artifact (`kilo-vscode`). diff --git a/bun.lock b/bun.lock index bb57c5dc7eb..40d0cc2ff00 100644 --- a/bun.lock +++ b/bun.lock @@ -241,9 +241,10 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", "lucide-solid": "0.576.0", + "strip-ansi": "7.1.2", }, "devDependencies": { - "@playwright/test": "1.58.2", + "@playwright/test": "1.57.0", "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@storybook/addon-themes": "10.2.10", @@ -317,7 +318,8 @@ "name": "@kilocode/cli", "version": "7.0.50", "bin": { - "opencode": "./bin/opencode", + "kilo": "./bin/kilo", + "kilocode": "./bin/kilo", }, "dependencies": { "@actions/core": "1.11.1", @@ -376,7 +378,7 @@ "clipboardy": "4.0.0", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "fuzzysort": "3.1.0", "glob": "13.0.5", "google-auth-library": "10.5.0", @@ -415,6 +417,7 @@ "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1", "@standard-schema/spec": "1.0.0", "@tsconfig/bun": "catalog:", @@ -425,8 +428,8 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -585,8 +588,8 @@ "ai": "5.0.124", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1452,7 +1455,7 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], "@posthog/core": ["@posthog/core@1.23.4", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-gSM1gnIuw5UOBUOTz0IhCTH8jOHoFr5rzSDb5m7fn9ofLHvz3boZT1L1f+bcuk+mvzNJfrJ3ByVQGKmUQnKQ8g=="], @@ -2664,9 +2667,9 @@ "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.12-a5629fb", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l+p4QOMvPGYBYEE9NBlU7diu+NSlxuOUwi0I7i01Uj1PpfU0NxhPzaks/9q1MDw4FAPP8vdD0dOhoqosKtRWWQ=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], - "drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="], + "drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -3612,9 +3615,9 @@ "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], @@ -4680,8 +4683,6 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/app/@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], - "@opencode-ai/app/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/app/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], @@ -5002,6 +5003,8 @@ "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "kilo-code/@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "kilo-code/diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], "kilo-code/prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -5468,8 +5471,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/app/@playwright/test/playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], - "@opencode-ai/app/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "@opencode-ai/desktop-electron/@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=="], @@ -5814,6 +5815,8 @@ "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "kilo-code/@playwright/test/playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + "kilo-code/solid-js/seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], "kilo-code/solid-js/seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], @@ -5970,10 +5973,6 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/app/@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - - "@opencode-ai/app/@playwright/test/playwright/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], - "@opencode-ai/app/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@opencode-ai/app/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -6354,6 +6353,10 @@ "iconv-corefoundation/cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "kilo-code/@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "kilo-code/@playwright/test/playwright/playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "mocha/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "mocha/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/flake.lock b/flake.lock index 80484256d69..d98f5a271f4 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1772956932, - "narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=", + "lastModified": 1773628058, + "narHash": "sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "608d0cadfed240589a7eea422407a547ad626a14", + "rev": "f8573b9c935cfaa162dd62cc9e75ae2db86f85df", "type": "github" }, "original": { diff --git a/package.json b/package.json index 3cbf2fa0828..b31d980b16e 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "ai": "5.0.124", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -80,10 +80,10 @@ "dependencies": { "@aws-sdk/client-s3": "3.933.0", "@kilocode/plugin": "workspace:*", - "@kilocode/sdk": "workspace:*", - "@morphllm/morphsdk": "0.2.141", "@opencode-ai/script": "workspace:*", - "typescript": "catalog:" + "@kilocode/sdk": "workspace:*", + "typescript": "catalog:", + "@morphllm/morphsdk": "0.2.141" }, "repository": { "type": "git", diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md index 59662dbea56..8bfbd111b25 100644 --- a/packages/app/e2e/AGENTS.md +++ b/packages/app/e2e/AGENTS.md @@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => { - `closeDialog(page, dialog)` - Close any dialog - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar - `withSession(sdk, title, callback)` - Create temp session +- `withProject(...)` - Create temp project/workspace +- `sessionIDFromUrl(url)` - Read session ID from URL +- `slugFromUrl(url)` - Read workspace slug from URL +- `waitSlug(page, skip?)` - Wait for resolved workspace slug +- `trackSession(sessionID, directory?)` - Register session for fixture cleanup +- `trackDirectory(directory)` - Register directory for fixture cleanup - `clickListItem(container, filter)` - Click list item by key/text **Selectors** (`selectors.ts`): @@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test" ### Error Handling -Tests should clean up after themselves: +Tests should clean up after themselves. Prefer fixture-managed cleanup: ```typescript test("test with cleanup", async ({ page, sdk, gotoSession }) => { @@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => { }) ``` +- Prefer `withSession(...)` for temp sessions +- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)` +- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency +- Avoid calling `sdk.session.delete(...)` directly + ### Timeouts Default: 60s per test, 10s per assertion. Override when needed: @@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings 1. Choose appropriate folder or create new one 2. Import from `../fixtures` 3. Use helper functions from `../actions` and `../selectors` -4. Clean up any created resources -5. Use specific selectors (avoid CSS classes) -6. Test one feature per test file +4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs. +5. Clean up any created resources +6. Use specific selectors (avoid CSS classes) +7. Test one feature per test file ## Local Development diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index bf95feabcbf..2ea6dd7409b 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -3,12 +3,13 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { execSync } from "node:child_process" -import { modKey, serverUrl } from "./utils" +import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { - sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + sessionTimelineHeaderSelector, projectMenuTriggerSelector, + projectCloseMenuSelector, projectWorkspacesToggleSelector, titlebarRightSelector, popoverBodySelector, @@ -18,7 +19,6 @@ import { workspaceItemSelector, workspaceMenuTriggerSelector, } from "./selectors" -import type { createSdk } from "./utils" export async function defocus(page: Page) { await page @@ -61,9 +61,9 @@ export async function closeDialog(page: Page, dialog: Locator) { } export async function isSidebarClosed(page: Page) { - const main = page.locator("main") - const classes = (await main.getAttribute("class")) ?? "" - return classes.includes("xl:border-l") + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + await expect(button).toBeVisible() + return (await button.getAttribute("aria-expanded")) !== "true" } export async function toggleSidebar(page: Page) { @@ -75,48 +75,34 @@ export async function openSidebar(page: Page) { if (!(await isSidebarClosed(page))) return const button = page.getByRole("button", { name: /toggle sidebar/i }).first() - const visible = await button - .isVisible() - .then((x) => x) - .catch(() => false) + await button.click() - if (visible) await button.click() - if (!visible) await toggleSidebar(page) - - const main = page.locator("main") - const opened = await expect(main) - .not.toHaveClass(/xl:border-l/, { timeout: 1500 }) + const opened = await expect(button) + .toHaveAttribute("aria-expanded", "true", { timeout: 1500 }) .then(() => true) .catch(() => false) if (opened) return await toggleSidebar(page) - await expect(main).not.toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "true") } export async function closeSidebar(page: Page) { if (await isSidebarClosed(page)) return const button = page.getByRole("button", { name: /toggle sidebar/i }).first() - const visible = await button - .isVisible() - .then((x) => x) - .catch(() => false) + await button.click() - if (visible) await button.click() - if (!visible) await toggleSidebar(page) - - const main = page.locator("main") - const closed = await expect(main) - .toHaveClass(/xl:border-l/, { timeout: 1500 }) + const closed = await expect(button) + .toHaveAttribute("aria-expanded", "false", { timeout: 1500 }) .then(() => true) .catch(() => false) if (closed) return await toggleSidebar(page) - await expect(main).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") } export async function openSettings(page: Page) { @@ -204,7 +190,7 @@ export async function createTestProject() { stdio: "ignore", }) - return root + return resolveDirectory(root) } export async function cleanupTestProject(directory: string) { @@ -214,13 +200,40 @@ export async function cleanupTestProject(directory: string) { await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined) } +export function slugFromUrl(url: string) { + return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" +} + +export async function waitSlug(page: Page, skip: string[] = []) { + let prev = "" + let next = "" + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + if (skip.includes(slug)) return "" + if (slug !== prev) { + prev = slug + next = "" + return "" + } + next = slug + return slug + }, + { timeout: 45_000 }, + ) + .not.toBe("") + return next +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] } export async function hoverSessionItem(page: Page, sessionID: string) { - const sessionEl = page.locator(sessionItemSelector(sessionID)).first() + const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last() await expect(sessionEl).toBeVisible() await sessionEl.hover() return sessionEl @@ -231,7 +244,9 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { const scroller = page.locator(".scroll-view__viewport").first() await expect(scroller).toBeVisible() - await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + const header = page.locator(sessionTimelineHeaderSelector).first() + await expect(header).toBeVisible({ timeout: 30_000 }) + await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) const menu = page .locator(dropdownMenuContentSelector) @@ -247,7 +262,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { if (opened) return menu - const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() + const menuTrigger = header.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await page.waitForTimeout(100) // kilocode_change - wait before menu trigger click await menuTrigger.click() @@ -324,6 +339,57 @@ export async function clickListItem( return item } +async function status(sdk: ReturnType, sessionID: string) { + const data = await sdk.session + .status() + .then((x) => x.data ?? {}) + .catch(() => undefined) + return data?.[sessionID] +} + +async function stable(sdk: ReturnType, sessionID: string, timeout = 10_000) { + let prev = "" + await expect + .poll( + async () => { + const info = await sdk.session + .get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined) + if (!info) return true + const next = `${info.title}:${info.time.updated ?? info.time.created}` + if (next !== prev) { + prev = next + return false + } + return true + }, + { timeout }, + ) + .toBe(true) +} + +export async function waitSessionIdle(sdk: ReturnType, sessionID: string, timeout = 30_000) { + await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true) +} + +export async function cleanupSession(input: { + sessionID: string + directory?: string + sdk?: ReturnType +}) { + const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined) + if (!sdk) throw new Error("cleanupSession requires sdk or directory") + await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined) + const current = await status(sdk, input.sessionID).catch(() => undefined) + if (current && current.type !== "idle") { + await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined) + await waitSessionIdle(sdk, input.sessionID).catch(() => undefined) + } + await stable(sdk, input.sessionID).catch(() => undefined) + await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined) +} + export async function withSession( sdk: ReturnType, title: string, @@ -335,7 +401,7 @@ export async function withSession( try { return await callback(session) } finally { - await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: session.id }) } } @@ -448,6 +514,57 @@ export async function seedSessionPermission( return { id: result.id } } +export async function seedSessionTask( + sdk: ReturnType, + input: { + sessionID: string + description: string + prompt: string + subagentType?: string + }, +) { + const text = [ + "Your only valid response is one task tool call.", + `Use this JSON input: ${JSON.stringify({ + description: input.description, + prompt: input.prompt, + subagent_type: input.subagentType ?? "general", + })}`, + "Do not output plain text.", + "Wait for the task to start and return the child session id.", + ].join("\n") + + const result = await seed({ + sdk, + sessionID: input.sessionID, + prompt: text, + timeout: 90_000, + probe: async () => { + const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? []) + const part = messages + .flatMap((message) => message.parts) + .find((part) => { + if (part.type !== "tool" || part.tool !== "task") return false + if (part.state.input?.description !== input.description) return false + return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0 + }) + + if (!part) return + const id = part.state.metadata?.sessionId + if (typeof id !== "string" || !id) return + const child = await sdk.session + .get({ sessionID: id }) + .then((x) => x.data) + .catch(() => undefined) + if (!child?.id) return + return { sessionID: id } + }, + }) + + if (!result) throw new Error("Timed out seeding task tool") + return result +} + export async function seedSessionTodos( sdk: ReturnType, input: { @@ -522,32 +639,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) { const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() await expect(trigger).toHaveCount(1) + const menu = page + .locator(dropdownMenuContentSelector) + .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) }) + .first() + const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() + + const clicked = await trigger + .click({ timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (clicked) { + const opened = await menu + .waitFor({ state: "visible", timeout: 1500 }) + .then(() => true) + .catch(() => false) + if (opened) { + await expect(close).toBeVisible() + return menu + } + } + await trigger.focus() await page.keyboard.press("Enter") - const menu = page.locator(dropdownMenuContentSelector).first() const opened = await menu .waitFor({ state: "visible", timeout: 1500 }) .then(() => true) .catch(() => false) if (opened) { - const viewport = page.viewportSize() - const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 - const y = viewport ? Math.max(viewport.height - 5, 0) : 800 - await page.mouse.move(x, y) + await expect(close).toBeVisible() return menu } - await trigger.click({ force: true }) - - await expect(menu).toBeVisible() - - const viewport = page.viewportSize() - const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 - const y = viewport ? Math.max(viewport.height - 5, 0) : 800 - await page.mouse.move(x, y) - return menu + throw new Error(`Failed to open project menu: ${projectSlug}`) } export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { @@ -560,12 +687,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab if (current === enabled) return - await openProjectMenu(page, projectSlug) + const flip = async (timeout?: number) => { + const menu = await openProjectMenu(page, projectSlug) + const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() + await expect(toggle).toBeVisible() + return toggle.click({ force: true, timeout }) + } - const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first() - await expect(toggle).toBeVisible() - await page.waitForTimeout(100) // kilocode_change - wait before toggle click - await toggle.click({ force: true }) + const flipped = await flip(1500) + .then(() => true) + .catch(() => false) + + if (!flipped) await flip() const expected = enabled ? "New workspace" : "New session" await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() diff --git a/packages/app/e2e/app/home.spec.ts b/packages/app/e2e/app/home.spec.ts index f21dc40ec21..a3cedf7cb6f 100644 --- a/packages/app/e2e/app/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -1,17 +1,17 @@ import { test, expect } from "../fixtures" -import { serverName } from "../utils" +import { serverNamePattern } from "../utils" test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() - await expect(page.getByRole("button", { name: serverName })).toBeVisible() + await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() }) test("server picker dialog opens from home", async ({ page }) => { await page.goto("/") - const trigger = page.getByRole("button", { name: serverName }) + const trigger = page.getByRole("button", { name: serverNamePattern }) await expect(trigger).toBeVisible() await trigger.click() diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts index adbc83473be..2c63130f670 100644 --- a/packages/app/e2e/app/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" -import { serverName, serverUrl } from "../utils" -import { clickListItem, closeDialog, clickMenuItem } from "../actions" +import { serverNamePattern, serverUrls } from "../utils" +import { closeDialog, clickMenuItem } from "../actions" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" @@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => { const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() - const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first() - await expect(row).toBeVisible() + await expect(dialog.getByText(serverNamePattern).first()).toBeVisible() - const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first() + const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first() await expect(menuTrigger).toBeVisible() await menuTrigger.click({ force: true }) @@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => { await expect(menu).toBeVisible() await clickMenuItem(menu, /set as default/i) - await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl) - await expect(row.getByText("Default", { exact: true })).toBeVisible() + await expect + .poll(async () => + serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""), + ) + .toBe(true) + await expect(dialog.getByText("Default", { exact: true })).toBeVisible() await closeDialog(page, dialog) await ensurePopoverOpen() - const serverRow = popover.locator("button").filter({ hasText: serverName }).first() + const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first() await expect(serverRow).toBeVisible() await expect(serverRow.getByText("Default", { exact: true })).toBeVisible() }) diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts index 9d6091176ec..a4592ff1dbc 100644 --- a/packages/app/e2e/app/titlebar-history.spec.ts +++ b/packages/app/e2e/app/titlebar-history.spec.ts @@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd const link = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(link).toBeVisible() - await link.scrollIntoViewIfNeeded() await link.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) @@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async ( const second = page.locator(`[data-session-id="${b.id}"] a`).first() await expect(second).toBeVisible() - await second.scrollIntoViewIfNeeded() await second.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`)) @@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async ( const third = page.locator(`[data-session-id="${c.id}"] a`).first() await expect(third).toBeVisible() - await third.scrollIntoViewIfNeeded() await third.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`)) @@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g const link = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(link).toBeVisible() - await link.scrollIntoViewIfNeeded() await link.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 58c1f0a9af3..7e5d7bd6e7a 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise { await gotoSession() + const reviewPanel = page.locator("#review-panel") + const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first() await expect(treeToggle).toBeVisible() if (await expanded(treeToggle)) await treeToggle.click() @@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) => await expect(reviewToggle).toBeVisible() if (await expanded(reviewToggle)) await reviewToggle.click() await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#review-panel")).toHaveCount(0) + await expect(reviewPanel).toHaveAttribute("aria-hidden", "true") await page.keyboard.press(`${modKey}+Shift+R`) await expect(reviewToggle).toHaveAttribute("aria-expanded", "true") - await expect(page.locator("#review-panel")).toBeVisible() + await expect(reviewPanel).toHaveAttribute("aria-hidden", "false") await page.keyboard.press(`${modKey}+Shift+R`) await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#review-panel")).toHaveCount(0) + await expect(reviewPanel).toHaveAttribute("aria-hidden", "true") }) diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts index 44efb7f0046..a5872bdf87c 100644 --- a/packages/app/e2e/files/file-tree.spec.ts +++ b/packages/app/e2e/files/file-tree.spec.ts @@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession await tab.click() await expect(tab).toHaveAttribute("aria-selected", "true") + await toggle.click() + await expect(toggle).toHaveAttribute("aria-expanded", "false") + + await toggle.click() + await expect(toggle).toHaveAttribute("aria-expanded", "true") + await expect(allTab).toHaveAttribute("aria-selected", "true") + const viewer = page.locator('[data-component="file"][data-mode="text"]').first() await expect(viewer).toBeVisible() await expect(viewer).toContainText("export default function FileTree") diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index e990434fdc9..bc597590227 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ import { test as base, expect, type Page } from "@playwright/test" -import { cleanupTestProject, createTestProject, seedProjects } from "./actions" +import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" @@ -13,6 +13,8 @@ type TestFixtures = { directory: string slug: string gotoSession: (sessionID?: string) => Promise + trackSession: (sessionID: string, directory?: string) => void + trackDirectory: (directory: string) => void }) => Promise, options?: { extra?: string[] }, ) => Promise @@ -51,20 +53,36 @@ export const test = base.extend({ }, withProject: async ({ page }, use) => { await use(async (callback, options) => { - const directory = await createTestProject() - const slug = dirSlug(directory) - await seedStorage(page, { directory, extra: options?.extra }) + const root = await createTestProject() + const slug = dirSlug(root) + const sessions = new Map() + const dirs = new Set() + await seedStorage(page, { directory: root, extra: options?.extra }) const gotoSession = async (sessionID?: string) => { - await page.goto(sessionPath(directory, sessionID)) + await page.goto(sessionPath(root, sessionID)) await expect(page.locator(promptSelector)).toBeVisible() + const current = sessionIDFromUrl(page.url()) + if (current) trackSession(current) + } + + const trackSession = (sessionID: string, directory?: string) => { + sessions.set(sessionID, directory ?? root) + } + + const trackDirectory = (directory: string) => { + if (directory !== root) dirs.add(directory) } try { await gotoSession() - return await callback({ directory, slug, gotoSession }) + return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) } finally { - await cleanupTestProject(directory) + await Promise.allSettled( + Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), + ) + await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) + await cleanupTestProject(root) } }) }, diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index ce4d85b0987..585cb0ac8a6 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,29 +1,16 @@ import { test, expect } from "../fixtures" -import { openSidebar } from "../actions" +import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" test("dialog edit project updates name and startup script", async ({ page, withProject }) => { test.skip(process.platform === "win32", "Skipping on Windows due to hover/menu interaction issues") // kilocode_change await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async () => { + await withProject(async ({ slug }) => { await openSidebar(page) const open = async () => { - const header = page.locator(".group\\/project").first() - await header.hover() - await page.waitForTimeout(200) // kilocode_change - wait for hover state - const trigger = header.getByRole("button", { name: "More options" }).first() - await expect(trigger).toBeVisible() - await page.waitForTimeout(100) // kilocode_change - wait before trigger click - await trigger.click({ force: true }) - - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible() - - const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() - await expect(editItem).toBeVisible() - await page.waitForTimeout(100) // kilocode_change - wait before edit item click - await editItem.click({ force: true }) + const menu = await openProjectMenu(page, slug) + await clickMenuItem(menu, /^Edit$/i, { force: true }) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index 3319ed51968..fb87a833cb3 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -1,36 +1,8 @@ import { test, expect } from "../fixtures" import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions" -import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors" +import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can close a project via hover card close button", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const other = await createTestProject() - const otherSlug = dirSlug(other) - - try { - await withProject( - async () => { - await openSidebar(page) - - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.hover() - - const close = page.locator(projectCloseHoverSelector(otherSlug)).first() - await expect(close).toBeVisible() - await close.click() - - await expect(otherButton).toHaveCount(0) - }, - { extra: [other] }, - ) - } finally { - await cleanupTestProject(other) - } -}) - test("closing active project navigates to another open project", async ({ page, withProject }) => { test.skip(process.platform === "win32", "Skipping on Windows due to workspace interaction issues") // kilocode_change await page.setViewportSize({ width: 1400, height: 800 }) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 27186c9961a..1ff83d5d3d8 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,18 +1,39 @@ import { base64Decode } from "@opencode-ai/util/encode" +import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { - defocus, - createTestProject, - cleanupTestProject, - openSidebar, - setWorkspacesEnabled, - sessionIDFromUrl, -} from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" -import { createSdk, dirSlug, sessionPath } from "../utils" +import { dirSlug, resolveDirectory } from "../utils" -function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" +async function workspaces(page: Page, directory: string, enabled: boolean) { + await page.evaluate( + ({ directory, enabled }: { directory: string; enabled: boolean }) => { + const key = "opencode.global.dat:layout" + const raw = localStorage.getItem(key) + const data = raw ? JSON.parse(raw) : {} + const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {} + const current = + sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces) + ? sidebar.workspaces + : {} + const next = { ...current } + + if (enabled) next[directory] = true + if (!enabled) delete next[directory] + + localStorage.setItem( + key, + JSON.stringify({ + ...data, + sidebar: { + ...sidebar, + workspaces: next, + }, + }), + ) + }, + { directory, enabled }, + ) } test("can switch between projects from sidebar", async ({ page, withProject }) => { @@ -52,46 +73,39 @@ test("switching back to a project opens the latest workspace session", async ({ const other = await createTestProject() const otherSlug = dirSlug(other) - let rootDir: string | undefined - let workspaceDir: string | undefined - let sessionID: string | undefined - try { await withProject( - async ({ directory, slug }) => { - rootDir = directory + async ({ directory, slug, trackSession, trackDirectory }) => { await defocus(page) + await workspaces(page, directory, true) + await page.reload() + await expect(page.locator(promptSelector)).toBeVisible() await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const next = slugFromUrl(page.url()) - if (!next) return "" - if (next === slug) return "" - return next - }, - { timeout: 45_000 }, - ) - .not.toBe("") - - const workspaceSlug = slugFromUrl(page.url()) - workspaceDir = base64Decode(workspaceSlug) - if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`) + const raw = await waitSlug(page, [slug]) + const dir = base64Decode(raw) + if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) + const space = await resolveDirectory(dir) + const next = dirSlug(space) + trackDirectory(space) await openSidebar(page) - const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first() - await expect(workspace).toBeVisible() - await workspace.hover() + const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() + await expect(item).toBeVisible() + await item.hover() - const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first() - await expect(newSession).toBeVisible() - await newSession.click({ force: true }) + const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() + await expect(btn).toBeVisible() + await btn.click({ force: true }) - await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`)) + // A new workspace can be discovered via a transient slug before the route and sidebar + // settle to the canonical workspace path on Windows, so interact with either and assert + // against the resolved workspace slug. + await waitSlug(page) + await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -104,9 +118,9 @@ test("switching back to a project opens the latest workspace session", async ({ const created = sessionIDFromUrl(page.url()) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) - sessionID = created + trackSession(created, space) - await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`)) + await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await openSidebar(page) @@ -125,20 +139,6 @@ test("switching back to a project opens the latest workspace session", async ({ { extra: [other] }, ) } finally { - if (sessionID) { - const id = sessionID - const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x) - await Promise.all( - dirs.map((directory) => - createSdk(directory) - .session.delete({ sessionID: id }) - .catch(() => undefined), - ), - ) - } - if (workspaceDir) { - await cleanupTestProject(workspaceDir) - } await cleanupTestProject(other) } }) diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index d46e0836a18..d9e8ab77d9e 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,14 +1,10 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions" +import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" -function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" -} - async function waitWorkspaceReady(page: Page, slug: string) { await openSidebar(page) await expect @@ -31,20 +27,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - if (!slug) return "" - if (slug === root) return "" - if (seen.includes(slug)) return "" - return slug - }, - { timeout: 45_000 }, - ) - .not.toBe("") - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [root, ...seen]) const directory = base64Decode(slug) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) return { slug, directory } @@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - await expect.poll(() => slugFromUrl(page.url())).toBe(slug) - await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`)) + const next = await waitSlug(page) + await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + return next } async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - await openWorkspaceNewSession(page, slug) + const next = await openWorkspaceNewSession(page, slug) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(slug) + await expect.poll(() => slugFromUrl(page.url())).toBe(next) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`)) - return sessionID + await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next } } async function sessionDirectory(directory: string, sessionID: string) { @@ -98,48 +82,29 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a test.skip(process.platform === "win32", "Skipping on Windows due to workspace interaction issues") // kilocode_change await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ directory, slug: root }) => { - const workspaces = [] as { slug: string; directory: string }[] - const sessions = [] as string[] + await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { + await openSidebar(page) + await setWorkspacesEnabled(page, root, true) - try { - await openSidebar(page) - await setWorkspacesEnabled(page, root, true) + const first = await createWorkspace(page, root, []) + trackDirectory(first.directory) + await waitWorkspaceReady(page, first.slug) - const first = await createWorkspace(page, root, []) - workspaces.push(first) - await waitWorkspaceReady(page, first.slug) + const second = await createWorkspace(page, root, [first.slug]) + trackDirectory(second.directory) + await waitWorkspaceReady(page, second.slug) - const second = await createWorkspace(page, root, [first.slug]) - workspaces.push(second) - await waitWorkspaceReady(page, second.slug) + const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) + trackSession(firstSession.sessionID, first.directory) - const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) - sessions.push(firstSession) + const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) + trackSession(secondSession.sessionID, second.directory) - const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) - sessions.push(secondSession) + const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) + trackSession(thirdSession.sessionID, first.directory) - const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) - sessions.push(thirdSession) - - await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory) - await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory) - await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory) - } finally { - const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)] - await Promise.all( - sessions.map((sessionID) => - Promise.all( - dirs.map((dir) => - createSdk(dir) - .session.delete({ sessionID }) - .catch(() => undefined), - ), - ), - ), - ) - await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory))) - } + await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory) + await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory) + await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory) }) }) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index abb8755a87e..d27836f5a96 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -14,14 +14,12 @@ import { openSidebar, openWorkspaceMenu, setWorkspacesEnabled, + slugFromUrl, + waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { createSdk, dirSlug } from "../utils" -function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" -} - async function setupWorkspaceTest(page: Page, project: { slug: string }) { const rootSlug = project.slug await openSidebar(page) @@ -29,17 +27,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [rootSlug]) const dir = base64Decode(slug) await openSidebar(page) @@ -95,18 +83,7 @@ test.skip("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - - await expect - .poll( - () => { - const currentSlug = slugFromUrl(page.url()) - return currentSlug.length > 0 && currentSlug !== slug - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const workspaceSlug = slugFromUrl(page.url()) + const workspaceSlug = await waitSlug(page, [slug]) const workspaceDir = base64Decode(workspaceSlug) await openSidebar(page) @@ -290,7 +267,7 @@ test.skip("can delete a workspace", async ({ page, withProject }) => { await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) await expect .poll( @@ -349,9 +326,6 @@ test.skip("can reorder workspaces by drag and drop", async ({ page, withProject const src = page.locator(workspaceItemSelector(from)).first() const dst = page.locator(workspaceItemSelector(to)).first() - await src.scrollIntoViewIfNeeded() - await dst.scrollIntoViewIfNeeded() - const a = await src.boundingBox() const b = await dst.boundingBox() if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") @@ -370,17 +344,7 @@ test.skip("can reorder workspaces by drag and drop", async ({ page, withProject for (const _ of [0, 1]) { const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug && slug !== prev - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [rootSlug, prev]) const dir = base64Decode(slug) workspaces.push({ slug, directory: dir }) diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts index ce9b1a7a3bb..51fbc3e4ae3 100644 --- a/packages/app/e2e/prompt/prompt-async.spec.ts +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -import { sessionIDFromUrl } from "../actions" +import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" + +const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() // Regression test for Issue #12453: the synchronous POST /message endpoint holds // the connection open while the agent works, causing "Failed to fetch" over @@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page, ) .toContain(token) } finally { - await sdk.session.delete({ sessionID }).catch(() => undefined) + await cleanupSession({ sdk, sessionID }) } }) + +test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => { + const prompt = page.locator(promptSelector) + const value = `restore ${Date.now()}` + + await page.route(`**/session/${session.id}/prompt_async`, (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ message: "e2e prompt failure" }), + }), + ) + + await gotoSession(session.id) + await prompt.click() + await page.keyboard.type(value) + await page.keyboard.press("Enter") + + await expect.poll(async () => text(await prompt.textContent())).toBe(value) + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 15_000 }, + ) + .toBe(0) + }) +}) diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts new file mode 100644 index 00000000000..36f83da80e6 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -0,0 +1,181 @@ +import type { ToolPart } from "@kilocode/sdk/v2/client" +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { withSession } from "../actions" +import { promptSelector } from "../selectors" + +const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() + +const isBash = (part: unknown): part is ToolPart => { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || part.tool !== "bash") return false + return "state" in part +} + +async function edge(page: Page, pos: "start" | "end") { + await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => { + const selection = window.getSelection() + if (!selection) return + + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + const nodes: Text[] = [] + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + nodes.push(node as Text) + } + + if (nodes.length === 0) { + const node = document.createTextNode("") + el.appendChild(node) + nodes.push(node) + } + + const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]! + const range = document.createRange() + range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + }, pos) +} + +async function wait(page: Page, value: string) { + await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) +} + +async function reply(sdk: Parameters[0], sessionID: string, token: string) { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + return messages + .filter((item) => item.info.role === "assistant") + .flatMap((item) => item.parts) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n") + }, + { timeout: 90_000 }, + ) + .toContain(token) +} + +async function shell(sdk: Parameters[0], sessionID: string, cmd: string, token: string) { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + const part = messages + .filter((item) => item.info.role === "assistant") + .flatMap((item) => item.parts) + .filter(isBash) + .find((item) => item.state.input?.command === cmd && item.state.status === "completed") + + if (!part || part.state.status !== "completed") return + return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output + }, + { timeout: 90_000 }, + ) + .toContain(token) +} + +test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + const firstToken = `E2E_HISTORY_ONE_${Date.now()}` + const secondToken = `E2E_HISTORY_TWO_${Date.now()}` + const first = `Reply with exactly: ${firstToken}` + const second = `Reply with exactly: ${secondToken}` + const draft = `draft ${Date.now()}` + + await prompt.click() + await page.keyboard.type(first) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, firstToken) + + await prompt.click() + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, secondToken) + + await prompt.click() + await page.keyboard.type(draft) + await wait(page, draft) + + await edge(page, "start") + await page.keyboard.press("ArrowUp") + await wait(page, second) + + await page.keyboard.press("ArrowUp") + await wait(page, first) + + await page.keyboard.press("ArrowDown") + await wait(page, second) + + await page.keyboard.press("ArrowDown") + await wait(page, draft) + }) +}) + +test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + const firstToken = `E2E_SHELL_ONE_${Date.now()}` + const secondToken = `E2E_SHELL_TWO_${Date.now()}` + const normalToken = `E2E_NORMAL_${Date.now()}` + const first = `echo ${firstToken}` + const second = `echo ${secondToken}` + const normal = `Reply with exactly: ${normalToken}` + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.type(first) + await page.keyboard.press("Enter") + await wait(page, "") + await shell(sdk, session.id, first, firstToken) + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await shell(sdk, session.id, second, secondToken) + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.press("ArrowUp") + await wait(page, second) + + await page.keyboard.press("ArrowUp") + await wait(page, first) + + await page.keyboard.press("ArrowDown") + await wait(page, second) + + await page.keyboard.press("ArrowDown") + await wait(page, "") + + await page.keyboard.press("Escape") + await wait(page, "") + + await prompt.click() + await page.keyboard.type(normal) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, normalToken) + + await prompt.click() + await page.keyboard.press("ArrowUp") + await wait(page, normal) + }) +}) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts new file mode 100644 index 00000000000..b2d195ed747 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -0,0 +1,62 @@ +import type { ToolPart } from "@kilocode/sdk/v2/client" +import { test, expect } from "../fixtures" +import { sessionIDFromUrl } from "../actions" +import { promptSelector } from "../selectors" +import { createSdk } from "../utils" + +const isBash = (part: unknown): part is ToolPart => { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || part.tool !== "bash") return false + return "state" in part +} + +test("shell mode runs a command in the project directory", async ({ page, withProject }) => { + test.setTimeout(120_000) + + await withProject(async ({ directory, gotoSession, trackSession }) => { + const sdk = createSdk(directory) + const prompt = page.locator(promptSelector) + const cmd = process.platform === "win32" ? "dir" : "ls" + + await gotoSession() + await prompt.click() + await page.keyboard.type("!") + await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) + + await page.keyboard.type(cmd) + await page.keyboard.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + + const id = sessionIDFromUrl(page.url()) + if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) + trackSession(id, directory) + + await expect + .poll( + async () => { + const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? []) + const msg = list.findLast( + (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory, + ) + if (!msg) return + + const part = msg.parts + .filter(isBash) + .find((item) => item.state.input?.command === cmd && item.state.status === "completed") + + if (!part || part.state.status !== "completed") return + const output = + typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output + if (!output.includes("README.md")) return + + return { cwd: directory, output } + }, + { timeout: 90_000 }, + ) + .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") })) + + await expect(prompt).toHaveText("") + }) +}) diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts new file mode 100644 index 00000000000..1363f79102c --- /dev/null +++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { withSession } from "../actions" + +const shareDisabled = process.env.KILO_DISABLE_SHARE === "true" || process.env.KILO_DISABLE_SHARE === "1" + +async function seed(sdk: Parameters[0], sessionID: string) { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: "e2e share seed" }], + }) + + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0) +} + +test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => { + test.skip(shareDisabled, "Share is disabled in this environment (KILO_DISABLE_SHARE).") + + await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => { + const prompt = page.locator(promptSelector) + + await seed(sdk, session.id) + await gotoSession(session.id) + + await prompt.click() + await page.keyboard.type("/share") + await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() + + await prompt.click() + await page.keyboard.type("/unshare") + await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() + }) +}) diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts index ff9f5daf0d4..0466d0988c8 100644 --- a/packages/app/e2e/prompt/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -import { sessionIDFromUrl, withSession } from "../actions" +import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { test.setTimeout(120_000) @@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) .toContain(token) } finally { page.off("pageerror", onPageError) - await sdk.session.delete({ sessionID }).catch(() => undefined) + await cleanupSession({ sdk, sessionID }) } if (pageErrors.length > 0) { diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b52..002ac2114c4 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' export const projectSwitchSelector = (slug: string) => `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]` -export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]` - export const projectMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]` @@ -53,6 +51,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte export const inlineInputSelector = '[data-component="inline-input"]' +export const sessionTimelineHeaderSelector = "[data-session-title]" + export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const workspaceItemSelector = (slug: string) => diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts new file mode 100644 index 00000000000..ac2dca33c80 --- /dev/null +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -0,0 +1,37 @@ +import { seedSessionTask, withSession } from "../actions" +import { test, expect } from "../fixtures" + +test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + const errs: string[] = [] + const onError = (err: Error) => { + errs.push(err.message) + } + page.on("pageerror", onError) + + await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { + const child = await seedSessionTask(sdk, { + sessionID: session.id, + description: "Open child session", + prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", + }) + + try { + await gotoSession(session.id) + + const link = page + .locator("a.subagent-link") + .filter({ hasText: /open child session/i }) + .first() + await expect(link).toBeVisible({ timeout: 30_000 }) + await link.click() + + await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) + await page.waitForTimeout(1000) + expect(errs).toEqual([]) + } finally { + page.off("pageerror", onError) + } + }) +}) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 480c5159ca2..aa39826b714 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" +import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" import { permissionDockSelector, promptSelector, @@ -26,7 +26,7 @@ async function withDockSession( try { return await fn(session) } finally { - await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: session.id }) } } @@ -315,7 +315,7 @@ test("child session question request blocks parent dock and unblocks after submi await expect(page.locator(promptSelector)).toBeVisible() }) } finally { - await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: child.id }) } }) }) @@ -362,7 +362,7 @@ test("child session permission request blocks parent dock and supports allow onc }, ) } finally { - await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: child.id }) } }) }) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index d5e7638fbf4..17de59f79bb 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -45,7 +45,7 @@ async function seedConversation(input: { .toBe(true) if (!userMessageID) throw new Error("Expected a user message id") - await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 }) + await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 }) return { prompt, userMessageID } } @@ -125,7 +125,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr .toBeUndefined() await expect(seeded.prompt).not.toContainText(token) - await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible() + await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) }) }) }) @@ -161,8 +161,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`) const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`) - await expect(firstMessage.first()).toBeVisible() - await expect(secondMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) await second.prompt.click() await page.keyboard.press(`${modKey}+A`) @@ -179,7 +179,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro }) .toBe(second.userMessageID) - await expect(firstMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) await expect(secondMessage).toHaveCount(0) await second.prompt.click() @@ -213,7 +213,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro }) .toBe(second.userMessageID) - await expect(firstMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) await expect(secondMessage).toHaveCount(0) await second.prompt.click() @@ -229,8 +229,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro }) .toBeUndefined() - await expect(firstMessage.first()).toBeVisible() - await expect(secondMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) }) }) }) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 9faf86b064e..1dc05a14e0d 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -7,7 +7,7 @@ import { openSharePopover, withSession, } from "../actions" -import { sessionItemSelector, inlineInputSelector } from "../selectors" +import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors" const shareDisabled = process.env.KILO_DISABLE_SHARE === "true" || process.env.KILO_DISABLE_SHARE === "1" @@ -39,12 +39,14 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } await withSession(sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id) await gotoSession(session.id) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) + await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText( + originalTitle, + ) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) - const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() + const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first() await expect(input).toBeVisible() await expect(input).toBeFocused() await input.fill(renamedTitle) @@ -61,7 +63,9 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } ) .toBe(renamedTitle) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) + await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText( + renamedTitle, + ) }) }) diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index 5e98bd158a1..e0d590b31af 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { await closeDialog(page, dialog) - const main = page.locator("main") - const initialClasses = (await main.getAttribute("class")) ?? "" - const initiallyClosed = initialClasses.includes("xl:border-l") + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true" await page.keyboard.press(`${modKey}+Shift+H`) - await page.waitForTimeout(100) + await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false") - const afterToggleClasses = (await main.getAttribute("class")) ?? "" - const afterToggleClosed = afterToggleClasses.includes("xl:border-l") + const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true" expect(afterToggleClosed).toBe(!initiallyClosed) await page.keyboard.press(`${modKey}+Shift+H`) - await page.waitForTimeout(100) + await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true") - const finalClasses = (await main.getAttribute("class")) ?? "" - const finalClosed = finalClasses.includes("xl:border-l") + const finalClosed = (await button.getAttribute("aria-expanded")) !== "true" expect(finalClosed).toBe(initiallyClosed) }) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index e37f94f3a7a..d10fca0e490 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" -import { closeSidebar, hoverSessionItem } from "../actions" -import { projectSwitchSelector, sessionItemSelector } from "../selectors" +import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" +import { projectSwitchSelector } from "../selectors" test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { const stamp = Date.now() @@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p await gotoSession(one.id) await closeSidebar(page) + const oneItem = page.locator(`[data-session-id="${one.id}"]`).last() + const twoItem = page.locator(`[data-session-id="${two.id}"]`).last() + const project = page.locator(projectSwitchSelector(slug)).first() await expect(project).toBeVisible() await project.hover() - await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible() - await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible() + await expect(oneItem).toBeVisible() + await expect(twoItem).toBeVisible() const item = await hoverSessionItem(page, one.id) await item @@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p .first() .click() - await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible() + await expect(twoItem).toBeVisible() } finally { - await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) - await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: one.id }) + await cleanupSession({ sdk, sessionID: two.id }) } }) diff --git a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts index cda2278a950..22f98e94caf 100644 --- a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openSidebar, withSession } from "../actions" +import { cleanupSession, openSidebar, withSession } from "../actions" import { promptSelector } from "../selectors" test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => { @@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl const target = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(target).toBeVisible() - await target.scrollIntoViewIfNeeded() await target.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/) } finally { - await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) - await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: one.id }) + await cleanupSession({ sdk, sessionID: two.id }) } }) diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts index 5c78c2220d2..c6bf3fa9ab0 100644 --- a/packages/app/e2e/sidebar/sidebar.spec.ts +++ b/packages/app/e2e/sidebar/sidebar.spec.ts @@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { await gotoSession() await openSidebar(page) + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + await expect(button).toHaveAttribute("aria-expanded", "true") await toggleSidebar(page) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") await toggleSidebar(page) - await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "true") }) test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => { @@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p await gotoSession(session1.id) await openSidebar(page) + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() await toggleSidebar(page) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") await gotoSession(session2.id) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") await page.reload() - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") const opened = await page.evaluate( () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened, diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts new file mode 100644 index 00000000000..f76a86cf70e --- /dev/null +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -0,0 +1,120 @@ +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { terminalSelector } from "../selectors" +import { terminalToggleKey, workspacePersistKey } from "../utils" + +type State = { + active?: string + all: Array<{ + id: string + title: string + titleNumber: number + buffer?: string + }> +} + +async function open(page: Page) { + const terminal = page.locator(terminalSelector) + const visible = await terminal.isVisible().catch(() => false) + if (!visible) await page.keyboard.press(terminalToggleKey) + await expect(terminal).toBeVisible() + await expect(terminal.locator("textarea")).toHaveCount(1) +} + +async function run(page: Page, cmd: string) { + const terminal = page.locator(terminalSelector) + await expect(terminal).toBeVisible() + await terminal.click() + await page.keyboard.type(cmd) + await page.keyboard.press("Enter") +} + +async function store(page: Page, key: string) { + return page.evaluate((key) => { + const raw = localStorage.getItem(key) + if (raw) return JSON.parse(raw) as State + + for (let i = 0; i < localStorage.length; i++) { + const next = localStorage.key(i) + if (!next?.endsWith(":workspace:terminal")) continue + const value = localStorage.getItem(next) + if (!value) continue + return JSON.parse(value) as State + } + }, key) +} + +test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const one = `E2E_TERM_ONE_${Date.now()}` + const two = `E2E_TERM_TWO_${Date.now()}` + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + + await gotoSession() + await open(page) + + await run(page, `echo ${one}`) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + await run(page, `echo ${two}`) + + await tabs + .filter({ hasText: /Terminal 1/ }) + .first() + .click() + + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return first.includes(one) && second.includes(two) + }, + { timeout: 30_000 }, + ) + .toBe(true) + }) +}) + +test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + + await gotoSession() + await open(page) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + const second = tabs.filter({ hasText: /Terminal 2/ }).first() + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + + await second.hover() + await page + .getByRole("button", { name: /close terminal/i }) + .nth(1) + .click({ force: true }) + + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + await expect(tabs).toHaveCount(1) + await expect(first).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + return { + count: state?.all.length ?? 0, + first: state?.all.some((item) => item.titleNumber === 1) ?? false, + } + }, + { timeout: 15_000 }, + ) + .toEqual({ count: 1, first: true }) + }) +}) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 42fd9a2c4ae..0ca3a901b4b 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,5 +1,5 @@ import { createKiloClient } from "@kilocode/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode, checksum } from "@opencode-ai/util/encode" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" export const serverUrl = `http://${serverHost}:${serverPort}` export const serverName = `${serverHost}:${serverPort}` +const localHosts = ["127.0.0.1", "localhost"] + +const serverLabels = (() => { + const url = new URL(serverUrl) + if (!localHosts.includes(url.hostname)) return [serverName] + return localHosts.map((host) => `${host}:${url.port}`) +})() + +export const serverNames = [...new Set(serverLabels)] + +export const serverUrls = serverNames.map((name) => `http://${name}`) + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`) + export const modKey = process.platform === "darwin" ? "Meta" : "Control" export const terminalToggleKey = "Control+Backquote" @@ -14,6 +30,12 @@ export function createSdk(directory?: string) { return createKiloClient({ baseUrl: serverUrl, directory, throwOnError: true }) } +export async function resolveDirectory(directory: string) { + return createSdk(directory) + .path.get() + .then((x) => x.data?.directory ?? directory) +} + export async function getWorktree() { const sdk = createSdk() const result = await sdk.path.get() @@ -33,3 +55,9 @@ export function dirPath(directory: string) { export function sessionPath(directory: string, sessionID?: string) { return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` } + +export function workspacePersistKey(directory: string, key: string) { + const head = directory.slice(0, 12) || "workspace" + const sum = checksum(directory) ?? "0" + return `opencode.workspace.${head}.${sum}.dat:workspace:${key}` +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9db8d1d28e9..313f5c1b08c 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -244,7 +244,6 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean - pendingAutoAccept: boolean }>({ popover: null, historyIndex: -1, @@ -253,7 +252,6 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, - pendingAutoAccept: false, }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) @@ -306,12 +304,6 @@ export const PromptInput: Component = (props) => { }), ) - createEffect( - on(sessionKey, () => { - setStore("pendingAutoAccept", false) - }), - ) - const historyComments = () => { const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) return prompt.context.items().flatMap((item) => { @@ -961,7 +953,7 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...local.model.variant.list()]) const accepting = createMemo(() => { const id = params.id - if (!id) return store.pendingAutoAccept + if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAccepting(id, sdk.directory) }) @@ -1215,9 +1207,9 @@ export const PromptInput: Component = (props) => { aria-multiline="true" aria-label={placeholder()} contenteditable="true" - autocapitalize="off" - autocorrect="off" - spellcheck={false} + autocapitalize={store.mode === "normal" ? "sentences" : "off"} + autocorrect={store.mode === "normal" ? "on" : "off"} + spellcheck={store.mode === "normal"} onInput={handleInput} onPaste={handlePaste} onCompositionStart={() => setComposing(true)} @@ -1340,7 +1332,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => { if (!params.id) { - setStore("pendingAutoAccept", (value) => !value) + permission.toggleAutoAcceptDirectory(sdk.directory) return } permission.toggleAutoAccept(params.id, sdk.directory) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 8e6b751e5be..a06fde67c23 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] +const optimistic: Array<{ + message: { + agent: string + model: { providerID: string; modelID: string } + variant?: string + } +}> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] +let params: { id?: string } = {} let selected = "/repo/worktree-a" +let variant: string | undefined const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] @@ -26,6 +35,7 @@ const clientFor = (directory: string) => { return { data: undefined } }, prompt: async () => ({ data: undefined }), + promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -40,7 +50,7 @@ beforeAll(async () => { mock.module("@solidjs/router", () => ({ useNavigate: () => () => undefined, - useParams: () => ({}), + useParams: () => params, })) mock.module("@kilocode/sdk/v2/client", () => ({ @@ -62,7 +72,7 @@ beforeAll(async () => { useLocal: () => ({ model: { current: () => ({ id: "model", provider: { id: "provider" } }), - variant: { current: () => undefined }, + variant: { current: () => variant }, }, agent: { current: () => ({ name: "agent" }), @@ -118,7 +128,11 @@ beforeAll(async () => { data: { command: [] }, session: { optimistic: { - add: () => undefined, + add: (value: { + message: { agent: string; model: { providerID: string; modelID: string }; variant?: string } + }) => { + optimistic.push(value) + }, remove: () => undefined, }, }, @@ -155,9 +169,12 @@ beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 enabledAutoAccept.length = 0 + optimistic.length = 0 + params = {} sentShell.length = 0 syncedDirectories.length = 0 selected = "/repo/worktree-a" + variant = undefined }) describe("prompt submit worktree selection", () => { @@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => { expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) }) + + test("includes the selected variant on optimistic prompts", async () => { + params = { id: "session-1" } + variant = "high" + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(optimistic).toHaveLength(1) + expect(optimistic[0]).toMatchObject({ + message: { + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + variant: "high", + }, + }) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 23e77c93e58..65919f59dc6 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -316,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { time: { created: Date.now() }, agent, model, + variant, } const addOptimisticMessage = () => diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index bb4d9812503..9b4551584c6 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -303,7 +303,12 @@ export function SessionHeader() { }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) - const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) const opening = createMemo(() => openRequest.app !== undefined) const selectApp = (app: OpenApp) => { diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index f2ecd51501e..5eb9314e8fa 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -8,8 +8,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" -const ROOT_CLASS = - "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16" +const ROOT_CLASS = "size-full flex flex-col" interface NewSessionViewProps { worktree: string @@ -50,33 +49,40 @@ export function NewSessionView(props: NewSessionViewProps) { return (
-
{language.t("command.session.new")}
-
- -
- {getDirectory(projectRoot())} - {getFilename(projectRoot())} +
+
+
+
{language.t("session.new.title")}
+
+
+
+ {getDirectory(projectRoot())} + {getFilename(projectRoot())} +
+
+
+ +
+ {label(current())} +
+
+ + {(project) => ( +
+
+ {language.t("session.new.lastModified")}  + + {DateTime.fromMillis(project().time.updated ?? project().time.created) + .setLocale(language.intl()) + .toRelative()} + +
+
+ )} +
+
-
- -
{label(current())}
-
- - {(project) => ( -
- -
- {language.t("session.new.lastModified")}  - - {DateTime.fromMillis(project().time.updated ?? project().time.created) - .setLocale(language.intl()) - .toRelative()} - -
-
- )} -
) } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 2d39799543c..586d5055072 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -166,6 +166,7 @@ const DICT: Record = { } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ + { locale: "en", match: (language) => language.startsWith("en") }, { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, { locale: "zh", match: (language) => language.startsWith("zh") }, { locale: "ko", match: (language) => language.startsWith("ko") }, @@ -237,6 +238,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont ) const locale = createMemo(() => normalizeLocale(store.locale)) + console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) const dict = createMemo(() => DICT[locale()]) diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index ab60df6e383..547de1c39ae 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@kilocode/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" -import { autoRespondsPermission } from "./permission-auto-respond" +import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => ({ @@ -60,4 +60,43 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) }) + + test("falls back to directory-level auto-accept", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/*`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) + }) + + test("session-level override takes precedence over directory-level", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/*`]: true, + [`${base64Encode(directory)}/root`]: false, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) + }) +}) + +describe("isDirectoryAutoAccepting", () => { + test("returns true when directory key is set", () => { + const directory = "/tmp/project" + const autoAccept = { [`${base64Encode(directory)}/*`]: true } + expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true) + }) + + test("returns false when directory key is not set", () => { + expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false) + }) + + test("returns false when directory key is explicitly false", () => { + const directory = "/tmp/project" + const autoAccept = { [`${base64Encode(directory)}/*`]: false } + expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) + }) }) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index 727ccc93756..b206deedff9 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -5,9 +5,19 @@ export function acceptKey(sessionID: string, directory?: string) { return `${base64Encode(directory)}/${sessionID}` } +export function directoryAcceptKey(directory: string) { + return `${base64Encode(directory)}/*` +} + function accepted(autoAccept: Record, sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) - return autoAccept[key] ?? autoAccept[sessionID] + const directoryKey = directory ? directoryAcceptKey(directory) : undefined + return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) +} + +export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { + const key = directoryAcceptKey(directory) + return autoAccept[key] ?? false } function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index bd7ce3ac7a9..6f890da7c96 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createMemo, onCleanup } from "solid-js" +import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@kilocode/sdk/v2/client" @@ -7,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" -import { acceptKey, autoRespondsPermission } from "./permission-auto-respond" +import { + acceptKey, + directoryAcceptKey, + isDirectoryAutoAccepting, + autoRespondsPermission, +} from "./permission-auto-respond" type PermissionRespondFn = (input: { sessionID: string @@ -76,6 +81,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }), ) + // When config has permission: "allow", auto-enable directory-level auto-accept + createEffect(() => { + if (!ready()) return + const directory = decode64(params.dir) + if (!directory) return + const [childStore] = globalSync.child(directory) + const perm = childStore.config.permission + if (typeof perm === "string" && perm === "allow") { + const key = directoryAcceptKey(directory) + if (store.autoAccept[key] === undefined) { + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + } + } + }) + const MAX_RESPONDED = 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000 const responded = new Map() @@ -119,6 +143,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) } + function isAutoAcceptingDirectory(directory: string) { + return isDirectoryAutoAccepting(store.autoAccept, directory) + } + function shouldAutoRespond(permission: PermissionRequest, directory?: string) { const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, session, permission, directory) @@ -142,6 +170,36 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) onCleanup(unsubscribe) + function enableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + + globalSDK.client.permission + .list({ directory }) + .then((x) => { + if (!isAutoAcceptingDirectory(directory)) return + for (const perm of x.data ?? []) { + if (!perm?.id) continue + if (!shouldAutoRespond(perm, directory)) continue + respondOnce(perm, directory) + } + }) + .catch(() => undefined) + } + + function disableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = false + }), + ) + } + function enable(sessionID: string, directory: string) { const key = acceptKey(sessionID, directory) const version = bumpEnableVersion(sessionID, directory) @@ -185,6 +243,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return shouldAutoRespond(permission, directory) }, isAutoAccepting, + isAutoAcceptingDirectory, toggleAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) { disable(sessionID, directory) @@ -193,6 +252,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple enable(sessionID, directory) }, + toggleAutoAcceptDirectory(directory: string) { + if (isAutoAcceptingDirectory(directory)) { + disableDirectory(directory) + return + } + enableDirectory(directory) + }, enableAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) return enable(sessionID, directory) @@ -201,6 +267,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple disable(sessionID, directory) }, permissionsEnabled, + isPermissionAllowAll(directory: string) { + const [childStore] = globalSync.child(directory) + const perm = childStore.config.permission + return typeof perm === "string" && perm === "allow" + }, } }, }) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index b4a9c48d0b4..2aede424f73 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -199,6 +199,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ parts: Part[] agent: string model: { providerID: string; modelID: string } + variant?: string }) { const message: Message = { id: input.messageID, @@ -207,6 +208,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ time: { created: Date.now() }, agent: input.agent, model: input.model, + variant: input.variant, } const [, setStore] = target() setOptimisticAdd(setStore as (...args: unknown[]) => void, { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index b1b83350694..c8183928cac 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -456,6 +456,7 @@ export const dict = { "session.todo.title": "المهام", "session.todo.collapse": "طي", "session.todo.expand": "توسيع", + "session.new.title": "ابنِ أي شيء", "session.new.worktree.main": "الفرع الرئيسي", "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})", "session.new.worktree.create": "إنشاء شجرة عمل جديدة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 55753571d6b..64621559996 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -459,6 +459,7 @@ export const dict = { "session.todo.title": "Tarefas", "session.todo.collapse": "Recolher", "session.todo.expand": "Expandir", + "session.new.title": "Crie qualquer coisa", "session.new.worktree.main": "Branch principal", "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})", "session.new.worktree.create": "Criar novo worktree", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 93d499d9e99..0fae276cd46 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -113,7 +113,6 @@ export const dict = { "dialog.model.empty": "Nema rezultata za modele", "dialog.model.manage": "Upravljaj modelima", "dialog.model.manage.description": "Prilagodi koji se modeli prikazuju u izborniku modela.", - "dialog.model.manage.provider.toggle": "Uključi/isključi sve {{provider}} modele", "dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje Kilo", "dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera", @@ -500,7 +499,6 @@ export const dict = { "session.review.loadingChanges": "Učitavanje izmjena...", "session.review.empty": "Još nema izmjena u ovoj sesiji", "session.review.noChanges": "Nema izmjena", - "session.review.noVcs": "Nije otkriven git VCS, stoga promjene sesije neće biti detektovane", "session.files.selectToOpen": "Odaberi datoteku za otvaranje", "session.files.all": "Sve datoteke", @@ -517,11 +515,7 @@ export const dict = { "session.todo.collapse": "Sažmi", "session.todo.expand": "Proširi", - "session.modeSwitch.switching": "Prebacivanje u {{mode}} način rada…", - "session.modeSwitch.waiting": "Čekanje na završetak trenutnog zadatka", - "session.modeSwitch.notAvailable": "Agent nije dostupan", - "session.modeSwitch.fallback": '"{{requested}}" nije pronađen, koristi se "{{actual}}"', - + "session.new.title": "Napravi bilo šta", "session.new.worktree.main": "Glavna grana", "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})", "session.new.worktree.create": "Kreiraj novi worktree", @@ -619,8 +613,6 @@ export const dict = { "settings.general.row.theme.description": "Prilagodi temu Kilo-a.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda", - "settings.general.row.reasoningSummaries.title": "Prikaži sažetke razmišljanja", - "settings.general.row.reasoningSummaries.description": "Prikaži sažetke razmišljanja modela u vremenskoj liniji", "settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata", "settings.general.row.shellToolPartsExpanded.description": diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index fa3cd900e55..57729d59b91 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -113,7 +113,6 @@ export const dict = { "dialog.model.empty": "Ingen modeller fundet", "dialog.model.manage": "Administrer modeller", "dialog.model.manage.description": "Tilpas hvilke modeller der vises i modelvælgeren.", - "dialog.model.manage.provider.toggle": "Skift alle {{provider}}-modeller", "dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af Kilo", "dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere", @@ -497,7 +496,6 @@ export const dict = { "session.review.loadingChanges": "Indlæser ændringer...", "session.review.empty": "Ingen ændringer i denne session endnu", "session.review.noChanges": "Ingen ændringer", - "session.review.noVcs": "Ingen git VCS registreret, så sessionsændringer vil ikke blive registreret", "session.files.selectToOpen": "Vælg en fil at åbne", "session.files.all": "Alle filer", "session.files.binaryContent": "Binær fil (indhold kan ikke vises)", @@ -512,11 +510,7 @@ export const dict = { "session.todo.collapse": "Skjul", "session.todo.expand": "Udvid", - "session.modeSwitch.switching": "Skifter til {{mode}}-tilstand…", - "session.modeSwitch.waiting": "Venter på at den aktuelle opgave er fuldført", - "session.modeSwitch.notAvailable": "Agent ikke tilgængelig", - "session.modeSwitch.fallback": '"{{requested}}" ikke fundet, bruger "{{actual}}"', - + "session.new.title": "Byg hvad som helst", "session.new.worktree.main": "Hovedgren", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opret nyt worktree", @@ -614,8 +608,6 @@ export const dict = { "settings.general.row.theme.description": "Tilpas hvordan Kilo er temabestemt.", "settings.general.row.font.title": "Skrifttype", "settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke", - "settings.general.row.reasoningSummaries.title": "Vis ræsonneringssammendrag", - "settings.general.row.reasoningSummaries.description": "Vis modelræsonneringssammendrag i tidslinjen", "settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele", "settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 9c4b8ccff2e..2be8c6a3840 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -467,6 +467,7 @@ export const dict = { "session.todo.title": "Aufgaben", "session.todo.collapse": "Einklappen", "session.todo.expand": "Ausklappen", + "session.new.title": "Baue, was du willst", "session.new.worktree.main": "Haupt-Branch", "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})", "session.new.worktree.create": "Neuen Worktree erstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 213d17a8abe..2e4b92956b8 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -511,11 +511,13 @@ export const dict = { "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", "session.review.empty": "No changes in this session yet", - "session.review.noVcs": "No git VCS detected, so session changes will not be detected", + "session.review.noVcs": "No Git Version Control System detected, changes not displayed", + "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", + "session.files.empty": "No files", "session.files.binaryContent": "Binary file (content cannot be displayed)", "session.messages.renderEarlier": "Render earlier messages", @@ -529,11 +531,7 @@ export const dict = { "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", - "session.modeSwitch.switching": "Switching to {{mode}} mode…", - "session.modeSwitch.waiting": "Waiting for current task to complete", - "session.modeSwitch.notAvailable": "Agent not available", - "session.modeSwitch.fallback": '"{{requested}}" not found, using "{{actual}}"', - + "session.new.title": "Build anything", "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.create": "Create new worktree", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index a4733422030..3152643c5b9 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -113,7 +113,6 @@ export const dict = { "dialog.model.empty": "Sin resultados de modelos", "dialog.model.manage": "Gestionar modelos", "dialog.model.manage.description": "Personalizar qué modelos aparecen en el selector de modelos.", - "dialog.model.manage.provider.toggle": "Alternar todos los modelos de {{provider}}", "dialog.model.unpaid.freeModels.title": "Modelos gratuitos proporcionados por Kilo", "dialog.model.unpaid.addMore.title": "Añadir más modelos de proveedores populares", @@ -501,7 +500,6 @@ export const dict = { "session.review.loadingChanges": "Cargando cambios...", "session.review.empty": "No hay cambios en esta sesión aún", "session.review.noChanges": "Sin cambios", - "session.review.noVcs": "No se detectó VCS de git, por lo que los cambios de sesión no se detectarán", "session.files.selectToOpen": "Selecciona un archivo para abrir", "session.files.all": "Todos los archivos", @@ -518,11 +516,7 @@ export const dict = { "session.todo.collapse": "Contraer", "session.todo.expand": "Expandir", - "session.modeSwitch.switching": "Cambiando al modo {{mode}}…", - "session.modeSwitch.waiting": "Esperando que la tarea actual se complete", - "session.modeSwitch.notAvailable": "Agente no disponible", - "session.modeSwitch.fallback": '"{{requested}}" no encontrado, usando "{{actual}}"', - + "session.new.title": "Construye lo que quieras", "session.new.worktree.main": "Rama principal", "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})", "session.new.worktree.create": "Crear nuevo árbol de trabajo", @@ -622,9 +616,6 @@ export const dict = { "settings.general.row.theme.description": "Personaliza el tema de Kilo.", "settings.general.row.font.title": "Fuente", "settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código", - "settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento", - "settings.general.row.reasoningSummaries.description": - "Mostrar resúmenes de razonamiento del modelo en la línea de tiempo", "settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell", "settings.general.row.shellToolPartsExpanded.description": diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 4a2bca5f291..7319472bcd1 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -463,6 +463,7 @@ export const dict = { "session.todo.title": "Tâches", "session.todo.collapse": "Réduire", "session.todo.expand": "Développer", + "session.new.title": "Créez ce que vous voulez", "session.new.worktree.main": "Branche principale", "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})", "session.new.worktree.create": "Créer un nouvel arbre de travail", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 68cc9b4045c..bef17be027d 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -457,6 +457,7 @@ export const dict = { "session.todo.title": "ToDo", "session.todo.collapse": "折りたたむ", "session.todo.expand": "展開", + "session.new.title": "何でも作る", "session.new.worktree.main": "メインブランチ", "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})", "session.new.worktree.create": "新しいワークツリーを作成", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 35f1c521140..bf8ecf67e6f 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -459,6 +459,7 @@ export const dict = { "session.todo.title": "할 일", "session.todo.collapse": "접기", "session.todo.expand": "펼치기", + "session.new.title": "무엇이든 만들기", "session.new.worktree.main": "메인 브랜치", "session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})", "session.new.worktree.create": "새 작업 트리 생성", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 23d22090064..bd38d490f04 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -116,7 +116,6 @@ export const dict = { "dialog.model.empty": "Ingen modellresultater", "dialog.model.manage": "Administrer modeller", "dialog.model.manage.description": "Tilpass hvilke modeller som vises i modellvelgeren.", - "dialog.model.manage.provider.toggle": "Veksle alle {{provider}}-modeller", "dialog.model.unpaid.freeModels.title": "Gratis modeller levert av Kilo", "dialog.model.unpaid.addMore.title": "Legg til flere modeller fra populære leverandører", @@ -501,7 +500,6 @@ export const dict = { "session.review.loadingChanges": "Laster endringer...", "session.review.empty": "Ingen endringer i denne sesjonen ennå", "session.review.noChanges": "Ingen endringer", - "session.review.noVcs": "Ingen git VCS oppdaget, så øktendringer vil ikke bli oppdaget", "session.files.selectToOpen": "Velg en fil å åpne", "session.files.all": "Alle filer", @@ -518,11 +516,7 @@ export const dict = { "session.todo.collapse": "Skjul", "session.todo.expand": "Utvid", - "session.modeSwitch.switching": "Bytter til {{mode}}-modus…", - "session.modeSwitch.waiting": "Venter på at gjeldende oppgave er ferdig", - "session.modeSwitch.notAvailable": "Agent ikke tilgjengelig", - "session.modeSwitch.fallback": '"{{requested}}" ikke funnet, bruker "{{actual}}"', - + "session.new.title": "Bygg hva som helst", "session.new.worktree.main": "Hovedgren", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opprett nytt worktree", @@ -622,8 +616,6 @@ export const dict = { "settings.general.row.theme.description": "Tilpass hvordan Kilo er tematisert.", "settings.general.row.font.title": "Skrift", "settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker", - "settings.general.row.reasoningSummaries.title": "Vis resonneringssammendrag", - "settings.general.row.reasoningSummaries.description": "Vis modellressonneringssammendrag i tidslinjen", "settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler", "settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index e441a6fa63f..a7d3a9c9932 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -458,6 +458,7 @@ export const dict = { "session.todo.title": "Zadania", "session.todo.collapse": "Zwiń", "session.todo.expand": "Rozwiń", + "session.new.title": "Zbuduj cokolwiek", "session.new.worktree.main": "Główna gałąź", "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})", "session.new.worktree.create": "Utwórz nowe drzewo robocze", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 7ad44cab232..82a2f60abd5 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -113,7 +113,6 @@ export const dict = { "dialog.model.empty": "Модели не найдены", "dialog.model.manage": "Управление моделями", "dialog.model.manage.description": "Настройте какие модели появляются в выборе модели", - "dialog.model.manage.provider.toggle": "Переключить все модели {{provider}}", "dialog.model.unpaid.freeModels.title": "Бесплатные модели от Kilo", "dialog.model.unpaid.addMore.title": "Добавьте больше моделей от популярных провайдеров", @@ -501,7 +500,6 @@ export const dict = { "session.review.loadingChanges": "Загрузка изменений...", "session.review.empty": "Изменений в этой сессии пока нет", "session.review.noChanges": "Нет изменений", - "session.review.noVcs": "Git VCS не обнаружен, поэтому изменения сеанса не будут отслеживаться", "session.files.selectToOpen": "Выберите файл, чтобы открыть", "session.files.all": "Все файлы", "session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)", @@ -516,11 +514,7 @@ export const dict = { "session.todo.collapse": "Свернуть", "session.todo.expand": "Развернуть", - "session.modeSwitch.switching": "Переключение в режим {{mode}}…", - "session.modeSwitch.waiting": "Ожидание завершения текущей задачи", - "session.modeSwitch.notAvailable": "Агент недоступен", - "session.modeSwitch.fallback": '"{{requested}}" не найден, используется "{{actual}}"', - + "session.new.title": "Создавайте что угодно", "session.new.worktree.main": "Основная ветка", "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})", "session.new.worktree.create": "Создать новый worktree", @@ -620,8 +614,6 @@ export const dict = { "settings.general.row.theme.description": "Настройте оформление Kilo.", "settings.general.row.font.title": "Шрифт", "settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода", - "settings.general.row.reasoningSummaries.title": "Показывать сводки рассуждений", - "settings.general.row.reasoningSummaries.description": "Отображать сводки рассуждений модели в хронологии", "settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell", "settings.general.row.shellToolPartsExpanded.description": diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index b52bd580677..3d3ccd4e04c 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -511,6 +511,7 @@ export const dict = { "session.todo.collapse": "ย่อ", "session.todo.expand": "ขยาย", + "session.new.title": "สร้างอะไรก็ได้", "session.new.worktree.main": "สาขาหลัก", "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})", "session.new.worktree.create": "สร้าง worktree ใหม่", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 10e72dc9851..76b4b283d7d 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -523,6 +523,7 @@ export const dict = { "session.todo.collapse": "Daralt", "session.todo.expand": "Genişlet", + "session.new.title": "İstediğini yap", "session.new.worktree.main": "Ana dal", "session.new.worktree.mainWithBranch": "Ana dal ({{branch}})", "session.new.worktree.create": "Yeni çalışma ağacı oluştur", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 15abb8437fd..5f4a64cea96 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -510,6 +510,7 @@ export const dict = { "session.todo.title": "待办事项", "session.todo.collapse": "折叠", "session.todo.expand": "展开", + "session.new.title": "构建任何东西", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支({{branch}})", "session.new.worktree.create": "创建新的 worktree", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 505cf2a530f..0d810b5f826 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -507,6 +507,7 @@ export const dict = { "session.todo.collapse": "折疊", "session.todo.expand": "展開", + "session.new.title": "建構任何東西", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支 ({{branch}})", "session.new.worktree.create": "建立新的 worktree", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index f9103c2be16..9e231e2d285 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1 +1,29 @@ -@import "@kilocode/kilo-ui/styles/tailwind"; /* kilocode_change */ +@import "@opencode-ai/ui/styles/tailwind"; + +@layer components { + [data-component="getting-started"] { + container-type: inline-size; + container-name: getting-started; + } + + [data-component="getting-started-actions"] { + display: flex; + flex-direction: column; + gap: 0.75rem; /* gap-3 */ + } + + [data-component="getting-started-actions"] > [data-component="button"] { + width: 100%; + } + + @container getting-started (min-width: 17rem) { + [data-component="getting-started-actions"] { + flex-direction: row; + align-items: center; + } + + [data-component="getting-started-actions"] > [data-component="button"] { + width: auto; + } + } +} diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 71b52180f2e..fdf321f2dc3 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,26 +1,27 @@ -import { createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" +import { useGlobalSDK } from "@/context/global-sdk" import { DataProvider } from "@opencode-ai/ui/context" +import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" - function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { - const params = useParams() const navigate = useNavigate() const sync = useSync() + const slug = createMemo(() => base64Encode(props.directory)) return ( navigate(`/${params.dir}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} + onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} + onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} > {props.children} @@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() + const location = useLocation() const language = useLanguage() - const [store, setStore] = createStore({ invalid: "" }) - const directory = createMemo(() => { - return decode64(params.dir) ?? "" - }) + const globalSDK = useGlobalSDK() + const directory = createMemo(() => decode64(params.dir) ?? "") + const [state, setState] = createStore({ invalid: "", resolved: "" }) createEffect(() => { if (!params.dir) return - if (directory()) return - if (store.invalid === params.dir) return - setStore("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) + const raw = directory() + if (!raw) { + if (state.invalid === params.dir) return + setState("invalid", params.dir) + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + return + } + + const current = params.dir + globalSDK + .createClient({ + directory: raw, + throwOnError: true, + }) + .path.get() + .then((x) => { + if (params.dir !== current) return + const next = x.data?.directory ?? raw + batch(() => { + setState("invalid", "") + setState("resolved", next) + }) + if (next === raw) return + const path = location.pathname.slice(current.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + if (params.dir !== current) return + batch(() => { + setState("invalid", "") + setState("resolved", raw) + }) + }) }) + return ( - - - - {props.children} - - + + {(resolved) => ( + + + {props.children} + + + )} ) } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e042cdaca65..9b12deb6506 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -10,9 +10,8 @@ import { ParentProps, Show, untrack, - type JSX, } from "solid-js" -import { A, useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" @@ -20,7 +19,6 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -59,7 +57,6 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { - childMapByParent, displayName, effectiveWorkspaceOrder, errorMessage, @@ -96,6 +93,7 @@ export default function Layout(props: ParentProps) { workspaceName: {} as Record, workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, + gettingStartedDismissed: false, }), ) @@ -157,6 +155,8 @@ export default function Layout(props: ParentProps) { const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] const navLeave = { current: undefined as number | undefined } const [sortNow, setSortNow] = createSignal(Date.now()) + const [sizing, setSizing] = createSignal(false) + let sizet: number | undefined let sortNowInterval: ReturnType | undefined const sortNowTimeout = setTimeout( () => { @@ -169,7 +169,7 @@ export default function Layout(props: ParentProps) { const aim = createAim({ enabled: () => !layout.sidebar.opened(), active: () => state.hoverProject, - el: () => state.nav, + el: () => state.nav?.querySelector("[data-component='sidebar-rail']") ?? state.nav, onActivate: (directory) => { globalSync.child(directory) setState("hoverProject", directory) @@ -181,9 +181,23 @@ export default function Layout(props: ParentProps) { if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) + if (sizet !== undefined) clearTimeout(sizet) + if (peekt !== undefined) clearTimeout(peekt) aim.reset() }) + onMount(() => { + const stop = () => setSizing(false) + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const setHoverProject = (value: string | undefined) => { @@ -194,12 +208,54 @@ export default function Layout(props: ParentProps) { const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) const setHoverSession = (id: string | undefined) => setState("hoverSession", id) + const disarm = () => { + if (navLeave.current === undefined) return + clearTimeout(navLeave.current) + navLeave.current = undefined + } + + const arm = () => { + if (layout.sidebar.opened()) return + if (state.hoverProject === undefined) return + disarm() + navLeave.current = window.setTimeout(() => { + navLeave.current = undefined + setHoverProject(undefined) + setState("hoverSession", undefined) + }, 300) + } + + const [peek, setPeek] = createSignal(undefined) + const [peeked, setPeeked] = createSignal(false) + let peekt: number | undefined + const hoverProjectData = createMemo(() => { const id = state.hoverProject if (!id) return return layout.projects.list().find((project) => project.worktree === id) }) + createEffect(() => { + const p = hoverProjectData() + if (p) { + if (peekt !== undefined) { + clearTimeout(peekt) + peekt = undefined + } + setPeek(p) + setPeeked(true) + return + } + + setPeeked(false) + if (peek() === undefined) return + if (peekt !== undefined) clearTimeout(peekt) + peekt = window.setTimeout(() => { + peekt = undefined + setPeek(undefined) + }, 180) + }) + createEffect(() => { if (!layout.sidebar.opened()) return setHoverProject(undefined) @@ -1047,18 +1103,13 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { - const key = workspaceKey(directory) const project = layout.projects .list() - .find( - (item) => - workspaceKey(item.worktree) === key || - (item.sandboxes ?? []).some((sandbox) => workspaceKey(sandbox) === key), - ) + .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => workspaceKey(root) === key || dirs.some((dir) => workspaceKey(dir) === key), + ([root, dirs]) => root === directory || dirs.includes(directory), ) if (known) return known[0] @@ -1130,6 +1181,12 @@ export default function Layout(props: ParentProps) { } const openSession = async (target: { directory: string; id: string }) => { if (!canOpen(target.directory)) return false + const [data] = globalSync.child(target.directory, { bootstrap: false }) + if (data.session.some((item) => item.id === target.id)) { + setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() }) + navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`) + return true + } const resolved = await globalSDK.client.session .get({ sessionID: target.id }) .then((x) => x.data) @@ -1820,7 +1877,8 @@ export default function Layout(props: ParentProps) { setHoverSession, } - const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { + const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -1846,12 +1904,19 @@ export default function Layout(props: ParentProps) { return (
- + {(p) => ( <>
@@ -1860,7 +1925,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" stopPropagation @@ -1869,7 +1934,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -1888,33 +1953,33 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" classList={{ "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, }} aria-label={language.t("common.moreOptions")} /> - + - showEditProjectDialog(p)}> + showEditProjectDialog(p())}> {language.t("common.edit")} toggleProjectWorkspaces(p)} + data-project={base64Encode(p().worktree)} + disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()} + onSelect={() => toggleProjectWorkspaces(p())} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -1925,8 +1990,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -1946,7 +2011,7 @@ export default function Layout(props: ParentProps) { size="large" icon="plus-small" class="w-full" - onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)} + onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)} > {language.t("command.session.new")} @@ -1954,7 +2019,7 @@ export default function Layout(props: ParentProps) {
@@ -1964,7 +2029,7 @@ export default function Layout(props: ParentProps) { > <>
-
@@ -1989,7 +2054,7 @@ export default function Layout(props: ParentProps) { @@ -2014,25 +2079,31 @@ export default function Layout(props: ParentProps) {
0 && providers.paid().length === 0), + hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0), }} > -
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
+
+
+
+
{language.t("sidebar.gettingStarted.title")}
+
+ {language.t("sidebar.gettingStarted.line1")} +
+
+ {language.t("sidebar.gettingStarted.line2")} +
+
+
+ + +
-
@@ -2042,33 +2113,27 @@ export default function Layout(props: ParentProps) { return (
-
+
+ + diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 76d885e24fe..f78f27a8541 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -163,7 +163,6 @@ const SessionHoverPreview = (props: { gutter={16} shift={-2} trigger={props.trigger} - mount={!props.mobile ? props.nav() : undefined} open={props.hoverSession() === props.session.id} onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} > diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 3c3652e38f3..fb66dcc975e 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { createSortable } from "@thisbeyond/solid-dnd" import { useLayout, type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" @@ -137,7 +135,7 @@ const ProjectTile = (props: { > - + props.showEditProjectDialog(props.project)}> {props.language.t("common.edit")} @@ -194,21 +192,6 @@ const ProjectPreviewPanel = (props: {
{displayName(props.project)}
- - { - event.stopPropagation() - props.setOpen(false) - props.ctx.closeProject(props.project.worktree) - }} - /> -
{props.language.t("sidebar.project.recentSessions")}
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index d813ef3e116..d3070e37491 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -1,4 +1,4 @@ -import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { DragDropProvider, DragDropSensors, @@ -35,10 +35,22 @@ export const SidebarContent = (props: { }): JSX.Element => { const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) const placement = () => (props.mobile ? "bottom" : "right") + let panel: HTMLDivElement | undefined + + createEffect(() => { + const el = panel + if (!el) return + if (expanded()) { + el.removeAttribute("inert") + return + } + el.setAttribute("inert", "") + }) return ( -
+
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
- {props.renderPanel()} +
{ + panel = el + }} + classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }} + aria-hidden={!expanded()} + > + {props.renderPanel()} +
) } diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 42d16830813..2529a999ae4 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -182,7 +182,7 @@ const WorkspaceActions = (props: { aria-label={props.language.t("common.moreOptions")} /> - + { if (!props.pendingRename()) return @@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: { loadMore: () => Promise language: ReturnType }): JSX.Element => ( -
@@ -1070,23 +1139,6 @@ export default function Page() { tabs().setActive(next) }) - createEffect( - on( - () => layout.fileTree.opened(), - (opened, prev) => { - if (prev === undefined) return - if (!isDesktop()) return - - if (opened) { - const active = tabs().active() - const tab = active === "review" || (!active && hasReview()) ? "changes" : "all" - layout.fileTree.setTab(tab) - } - }, - { defer: true }, - ), - ) - createEffect(() => { const id = params.id if (!id) return @@ -1147,7 +1199,7 @@ export default function Page() { const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight const overflow = max > 1 - const bottom = !overflow || el.scrollTop >= max - 2 + const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -1170,7 +1222,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1238,13 +1290,11 @@ export default function Page() { const el = scroller const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false + const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false dockHeight = next - if (stick) autoScroll.forceScrollToBottom() + if (stick) autoScroll.smoothScrollToBottom() if (el) scheduleScrollState(el) scrollSpy.markDirty() @@ -1295,9 +1345,9 @@ export default function Page() { {/* Session panel */}
{ content = el @@ -1394,17 +1445,27 @@ export default function Page() { /> - +
size.start()}> + { + size.touch() + layout.session.resize(width) + }} + /> +
- +
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 7f656934b8d..9457bafbad4 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -141,7 +141,7 @@ export function SessionComposerRegion(props: {
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 77643789d04..07df4305f0d 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) { ) return ( - + { scroll = el restoreScroll() diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 60b26cdf47c..be9656900d3 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,4 +1,5 @@ -import { batch } from "solid-js" +import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) @@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined return toIndex } + +export const createSizing = () => { + const [state, setState] = createStore({ active: false }) + let t: number | undefined + + const stop = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", false) + } + + const start = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", true) + } + + onMount(() => { + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + + onCleanup(() => { + if (t !== undefined) clearTimeout(t) + }) + + return { + active: () => state.active, + start, + touch() { + start() + t = window.setTimeout(stop, 120) + }, + } +} + +export type Sizing = ReturnType + +export const createPresence = (open: Accessor, wait = 200) => { + const [state, setState] = createStore({ + show: open(), + open: open(), + }) + let frame: number | undefined + let t: number | undefined + + const clear = () => { + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + } + + createEffect( + on(open, (next) => { + clear() + + if (next) { + if (state.show) { + setState("open", true) + return + } + + setState({ show: true, open: false }) + frame = requestAnimationFrame(() => { + frame = undefined + setState("open", true) + }) + return + } + + if (!state.show) return + setState("open", false) + t = window.setTimeout(() => { + t = undefined + setState("show", false) + }, wait) + }), + ) + + onCleanup(clear) + + return { + show: () => state.show, + open: () => state.open, + } +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 1f756fc2454..3ce9960d0d7 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,27 +1,31 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { + For, + Index, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, + startTransition, + type JSX, +} from "solid-js" +import { createStore } from "solid-js/store" +import { useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@kilocode/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" -import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import { SessionTimelineHeader } from "@/pages/session/session-timeline-header" type MessageComment = { path: string @@ -33,7 +37,9 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } + +const isDefaultSessionTitle = (title?: string) => + !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) + const [readySession, setReadySession] = createSignal("") + let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } + const scheduleReady = (sessionKey: string) => { + if (input.sessionKey() !== sessionKey) return + if (readySession() === sessionKey) return + setReadySession(sessionKey) + } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { + const switched = active !== sessionKey + if (switched) { + active = sessionKey + setReadySession("") + } + + const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey + const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey + + if (staging && !switched && shouldStage && frame !== undefined) return + cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey + + if (shouldStage) setReadySession("") if (!shouldStage) { - setState({ activeSession: "", count: total }) + setState({ + activeSession: "", + completedSession: isWindowed ? sessionKey : state.completedSession, + count: total, + }) + if (total <= 0) { + setReadySession("") + return + } + if (readySession() !== sessionKey) scheduleReady(sessionKey) return } let count = Math.min(total, input.config.init) + if (staging) count = Math.min(total, Math.max(count, state.count)) setState({ activeSession: sessionKey, count }) const step = () => { @@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) + startTransition(() => setState("count", count)) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined + scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) + const ready = createMemo(() => readySession() === input.sessionKey()) - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } + onCleanup(() => { + cancel() + }) + return { messages: stagedUserMessages, isStaging, ready } } export function MessageTimeline(props: { @@ -196,6 +231,7 @@ export function MessageTimeline(props: { onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void + onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -210,14 +246,19 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const params = useParams() - const navigate = useNavigate() - const sdk = useSDK() const sync = useSync() const settings = useSettings() - const dialog = useDialog() const language = useLanguage() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const trigger = (target: EventTarget | null) => { + const next = + target instanceof Element + ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]') + : undefined + if (!(next instanceof HTMLElement)) return + return next + } + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -230,28 +271,20 @@ export function MessageTimeline(props: { (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => { - const id = sessionID() - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) + const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle") const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID - if (parentID) { - const messages = sessionMessages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message && message.role === "user") return message.id + const messages = sessionMessages() + const message = pending() + if (message?.parentID) { + const result = Binary.search(messages, message.parentID, (item) => item.id) + const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID) + if (parent?.role === "user") return parent.id } - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } + if (sessionStatus() === "idle") return undefined + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id } - return undefined }) const info = createMemo(() => { @@ -259,9 +292,19 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) - const titleValue = createMemo(() => info()?.title) + const titleValue = createMemo(() => { + const title = info()?.title + if (!title) return + if (isDefaultSessionTitle(title)) return language.t("command.session.new") + return title + }) + const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title)) + const headerTitle = createMemo( + () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined), + ) + const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0)) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(titleValue() || parentID())) + const showHeader = createMemo(() => !!(headerTitle() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -269,212 +312,7 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) - - const [title, setTitle] = createStore({ - draft: "", - editing: false, - saving: false, - menuOpen: false, - pendingRename: false, - }) - let titleRef: HTMLInputElement | undefined - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - createEffect( - on( - sessionKey, - () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!sessionID()) return - setTitle({ editing: true, draft: titleValue() ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (title.saving) return - setTitle({ editing: false, saving: false }) - } - - const saveTitleEditor = async () => { - const id = sessionID() - if (!id) return - if (title.saving) return - - const next = title.draft.trim() - if (!next || next === (titleValue() ?? "")) { - setTitle({ editing: false, saving: false }) - return - } - - setTitle("saving", true) - await sdk.client.session - .update({ sessionID: id, title: next }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === id) - if (index !== -1) draft.session[index].title = next - }), - ) - setTitle({ editing: false, saving: false }) - }) - .catch((err) => { - setTitle("saving", false) - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) - return - } - if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) - return - } - navigate(`/${params.dir}/session`) - } - - const archiveSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return - - const sessions = sync.data.session ?? [] - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - await sdk.client.session - .update({ sessionID, time: { archived: Date.now() } }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const deleteSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return false - - const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - const result = await sdk.client.session - .delete({ sessionID }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set([sessionID]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } + const rendered = createMemo(() => staging.messages().map((message) => message.id)) return (
+ { const root = e.currentTarget @@ -532,9 +381,18 @@ export function MessageTimeline(props: { touchGesture = undefined }} onPointerDown={(e) => { + const next = trigger(e.target) + if (next) props.onPreserveScrollAnchor(next) + if (e.target !== e.currentTarget) return props.onMarkScrollGesture(e.currentTarget) }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return + const next = trigger(e.target) + if (!next) return + props.onPreserveScrollAnchor(next) + }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() @@ -543,134 +401,24 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} - onClick={props.onAutoScrollInteraction} + onClick={(e) => { + props.onAutoScrollInteraction(e) + }} class="relative min-w-0 w-full h-full" style={{ - "--session-title-height": showHeader() ? "40px" : "0px", + "--session-title-height": showHeader() ? "72px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > -
- -
-
-
- - - - - - {titleValue()} - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id)}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
-
-
- +
{(messageID) => { + // Capture at creation time: animate only messages added after the + // timeline finishes its initial backfill staging, plus the first + // turn while a brand new session is still using its default title. + const isNew = + staging.ready() || + (defaultTitle() && + sessionStatus() !== "idle" && + props.renderedUserMessages.length === 1 && + messageID === props.renderedUserMessages[0]?.id) const active = createMemo(() => activeMessageID() === messageID) const queued = createMemo(() => { if (active()) return false @@ -700,7 +457,10 @@ export function MessageTimeline(props: { return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + equals: (a, b) => { + if (a.length !== b.length) return false + return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment) + }, }) const commentCount = createMemo(() => comments().length) return ( @@ -713,7 +473,7 @@ export function MessageTimeline(props: { }} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, + "md:max-w-[500px] 2xl:max-w-[700px]": props.centered, }} > 0}> @@ -757,7 +517,7 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - status={active() ? sessionStatus() : undefined} + animate={isNew || active()} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts new file mode 100644 index 00000000000..23d866fb52e --- /dev/null +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import type { UserMessage } from "@kilocode/sdk/v2" +import { resetSessionModel, syncSessionModel } from "./session-model-helpers" + +const message = (input?: Partial>) => + ({ + id: "msg", + sessionID: "session", + role: "user", + time: { created: 1 }, + agent: input?.agent ?? "build", + model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: input?.variant, + }) as UserMessage + +describe("syncSessionModel", () => { + test("restores the last message model and variant", () => { + const calls: unknown[] = [] + + syncSessionModel( + { + agent: { + current() { + return undefined + }, + set(value) { + calls.push(["agent", value]) + }, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return { id: "claude-sonnet-4", provider: { id: "anthropic" } } + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }, + message({ variant: "high" }), + ) + + expect(calls).toEqual([ + ["agent", "build"], + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", "high"], + ]) + }) + + test("skips variant when the model falls back", () => { + const calls: unknown[] = [] + + syncSessionModel( + { + agent: { + current() { + return undefined + }, + set(value) { + calls.push(["agent", value]) + }, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return { id: "gpt-5", provider: { id: "openai" } } + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }, + message({ variant: "high" }), + ) + + expect(calls).toEqual([ + ["agent", "build"], + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ]) + }) +}) + +describe("resetSessionModel", () => { + test("restores the current agent defaults", () => { + const calls: unknown[] = [] + + resetSessionModel({ + agent: { + current() { + return { + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: "high", + } + }, + set() {}, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return undefined + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }) + + expect(calls).toEqual([ + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", "high"], + ]) + }) + + test("clears the variant when the agent has none", () => { + const calls: unknown[] = [] + + resetSessionModel({ + agent: { + current() { + return { + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + } + }, + set() {}, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return undefined + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }) + + expect(calls).toEqual([ + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", undefined], + ]) + }) +}) diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts new file mode 100644 index 00000000000..c02a455f03b --- /dev/null +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -0,0 +1,48 @@ +import type { UserMessage } from "@kilocode/sdk/v2" +import { batch } from "solid-js" + +type Local = { + agent: { + current(): + | { + model?: UserMessage["model"] + variant?: string + } + | undefined + set(name: string | undefined): void + } + model: { + set(model: UserMessage["model"] | undefined): void + current(): + | { + id: string + provider: { id: string } + } + | undefined + variant: { + set(value: string | undefined): void + } + } +} + +export const resetSessionModel = (local: Local) => { + const agent = local.agent.current() + if (!agent) return + batch(() => { + local.model.set(agent.model) + local.model.variant.set(agent.variant) + }) +} + +export const syncSessionModel = (local: Local, msg: UserMessage) => { + batch(() => { + local.agent.set(msg.agent) + local.model.set(msg.model) + }) + + const model = local.model.current() + if (!model) return + if (model.provider.id !== msg.model.providerID) return + if (model.id !== msg.model.modelID) return + local.model.variant.set(msg.variant) +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 55c1607a093..173b3db363d 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers" +import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { StickyAddButton } from "@/pages/session/review-tab" import { setSessionHandoff } from "@/pages/session/handoff" @@ -31,6 +31,7 @@ export function SessionSidePanel(props: { reviewPanel: () => JSX.Element activeDiff?: string focusReviewDiff: (path: string) => void + size: Sizing }) { const params = useParams() const layout = useLayout() @@ -46,8 +47,20 @@ export function SessionSidePanel(props: { const view = createMemo(() => layout.view(sessionKey)) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened())) + const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const open = createMemo(() => reviewOpen() || fileOpen()) const reviewTab = createMemo(() => isDesktop()) + const panelWidth = createMemo(() => { + if (!open()) return "0px" + if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)` + return `${layout.fileTree.width()}px` + }) + const reviewWidth = createMemo(() => { + if (!reviewOpen()) return "0px" + if (!fileOpen()) return "100%" + return `calc(100% - ${layout.fileTree.width()}px)` + }) + const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) @@ -60,6 +73,12 @@ export function SessionSidePanel(props: { return sync.data.session_diff[id] !== undefined }) + const reviewEmptyKey = createMemo(() => { + if (sync.project && !sync.project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.noChanges" + }) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { @@ -87,6 +106,21 @@ export function SessionSidePanel(props: { return out }) + const empty = (msg: string) => ( +
+
+
+
{msg}
+
+
+ ) + + const nofiles = createMemo(() => { + const state = file.tree.state("") + if (!state?.loaded) return false + return file.tree.children("").length === 0 + }) + const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) @@ -145,17 +179,8 @@ export function SessionSidePanel(props: { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, - fileTreeScrolled: false, }) - let changesEl: HTMLDivElement | undefined - let allEl: HTMLDivElement | undefined - - const syncFileTreeScrolled = (el?: HTMLDivElement) => { - const next = (el?.scrollTop ?? 0) > 0 - setStore("fileTreeScrolled", (current) => (current === next ? current : next)) - } - const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -176,11 +201,6 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } - createEffect(() => { - if (!layout.fileTree.opened()) return - syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl) - }) - createEffect(() => { if (!file.ready()) return @@ -203,151 +223,178 @@ export function SessionSidePanel(props: { }) return ( - + ) diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx new file mode 100644 index 00000000000..d10fe1a27e0 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -0,0 +1,522 @@ +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { prefersReducedMotion } from "@opencode-ai/ui/hooks" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" +import { showToast } from "@opencode-ai/ui/toast" +import { errorMessage } from "@/pages/layout/helpers" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" + +export function SessionTimelineHeader(props: { + centered: boolean + showHeader: () => boolean + sessionKey: () => string + sessionID: () => string | undefined + parentID: () => string | undefined + titleValue: () => string | undefined + headerTitle: () => string | undefined + placeholderTitle: () => boolean +}) { + const navigate = useNavigate() + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const dialog = useDialog() + const language = useLanguage() + const reduce = prefersReducedMotion + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + const [headerText, setHeaderText] = createStore({ + session: props.sessionKey(), + value: props.headerTitle(), + prev: undefined as string | undefined, + muted: props.placeholderTitle(), + prevMuted: false, + }) + let headerAnim: AnimationPlaybackControls | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined + let titleRef: HTMLInputElement | undefined + let headerRef: HTMLDivElement | undefined + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + + const clearHeaderAnim = () => { + headerAnim?.stop() + headerAnim = undefined + } + + const animateHeader = () => { + const el = headerRef + if (!el) return + + clearHeaderAnim() + if (!headerText.muted || reduce()) { + el.style.opacity = "1" + return + } + + headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 }) + headerAnim.finished.then(() => { + if (headerRef !== el) return + clearFadeStyles(el) + }) + } + + const clearTitleAnims = () => { + enterAnim?.stop() + enterAnim = undefined + leaveAnim?.stop() + leaveAnim = undefined + } + + const settleTitleEnter = () => { + if (enterRef) clearFadeStyles(enterRef) + } + + const hideLeave = () => { + if (!leaveRef) return + leaveRef.style.opacity = "0" + leaveRef.style.filter = "" + leaveRef.style.transform = "" + } + + const animateEnterSpan = () => { + if (!enterRef) return + if (reduce()) { + settleTitleEnter() + return + } + enterAnim = animate( + enterRef, + { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] }, + FAST_SPRING, + ) + enterAnim.finished.then(() => settleTitleEnter()) + } + + const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) + setHeaderText({ value: nextTitle, muted: nextMuted }) + + if (reduce()) { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + settleTitleEnter() + return + } + + if (leaveRef) { + leaveAnim = animate( + leaveRef, + { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] }, + FAST_SPRING, + ) + leaveAnim.finished.then(() => { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + }) + } + + animateEnterSpan() + } + + const fadeInTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + animateEnterSpan() + } + + const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + settleTitleEnter() + } + + createEffect( + on(props.showHeader, (show, prev) => { + if (!show) { + clearHeaderAnim() + return + } + if (show === prev) return + animateHeader() + }), + ) + + createEffect( + on( + () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const, + ([nextSession, nextTitle, nextMuted]) => { + if (nextSession !== headerText.session) { + setHeaderText("session", nextSession) + if (nextTitle && nextMuted) { + fadeInTitle(nextTitle, nextMuted) + return + } + snapTitle(nextTitle, nextMuted) + return + } + if (nextTitle === headerText.value && nextMuted === headerText.muted) return + if (!nextTitle) { + snapTitle(undefined, false) + return + } + if (!headerText.value) { + fadeInTitle(nextTitle, nextMuted) + return + } + if (title.saving || title.editing) { + snapTitle(nextTitle, nextMuted) + return + } + crossfadeTitle(nextTitle, nextMuted) + }, + ), + ) + + onCleanup(() => { + clearHeaderAnim() + clearTitleAnims() + }) + + const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) + + createEffect( + on( + props.sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!props.sessionID()) return + setTitle({ editing: true, draft: props.titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = props.sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (props.titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((session) => session.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: toastError(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((item) => item.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((item) => item.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: toastError(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived) + const index = sessions.findIndex((item) => item.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: toastError(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + const byParent = new Map() + + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((item) => !removed.has(item.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = props.parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(input: { sessionID: string }) { + const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) + + const handleDelete = async () => { + await deleteSession(input.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + return ( + +
{ + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + > +
+
+
+ +
+ +
+
+ + + + + {headerText.value} + + + {headerText.prev} + + + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index c8bfc140533..d5eac2322b7 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useTerminal, type LocalPTY } from "@/context/terminal" import { terminalTabLabel } from "@/pages/session/terminal-label" -import { focusTerminalById } from "@/pages/session/helpers" +import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" export function TerminalPanel() { @@ -33,8 +33,11 @@ export function TerminalPanel() { const opened = createMemo(() => view().terminal.opened()) const open = createMemo(() => isDesktop() && opened()) + const panel = createPresence(open) + const size = createSizing() const height = createMemo(() => layout.terminal.height()) const close = () => view().terminal.close() + let root: HTMLDivElement | undefined const [store, setStore] = createStore({ autoCreated: false, @@ -67,7 +70,7 @@ export function TerminalPanel() { on( () => terminal.active(), (activeId) => { - if (!activeId || !open()) return + if (!activeId || !panel.open()) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } @@ -76,6 +79,14 @@ export function TerminalPanel() { ), ) + createEffect(() => { + if (panel.open()) return + const active = document.activeElement + if (!(active instanceof HTMLElement)) return + if (!root?.contains(active)) return + active.blur() + }) + createEffect(() => { const dir = params.dir if (!dir) return @@ -133,120 +144,142 @@ export function TerminalPanel() { } return ( - +
- - -
- - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} +
+
size.start()}> + { + size.touch() + layout.terminal.resize(next) + }} + onCollapse={close} + /> +
+ +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+
+
+ {language.t("terminal.loading")}
-
{language.t("terminal.loading")}
-
- } - > - - - -
- terminal.open(id)} - class="!h-auto !flex-none" - > - - - - {(id) => ( - - {(pty) => } - - )} - - -
- - - -
-
-
-
- - {(id) => ( - - {(pty) => ( -
- terminal.clone(id)} /> + + + +
+ terminal.open(id)} + class="!h-auto !flex-none" + > + + + + {(id) => ( + + {(pty) => } + + )} + + +
+ + + +
+
+
+
+ + {(id) => ( + + {(pty) => ( +
+ terminal.clone(id)} + /> +
+ )} +
+ )} +
+
+
+ + + {(draggedId) => ( + + {(t) => ( +
+ {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: language.t as (key: string, vars?: Record) => string, + })}
)}
)}
-
-
- - - {(draggedId) => ( - - {(t) => ( -
- {terminalTabLabel({ - title: t.title, - titleNumber: t.titleNumber, - t: language.t as (key: string, vars?: Record) => string, - })} -
- )} -
- )} -
-
- - + + + +
) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 00385548357..47f12bba54a 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -261,24 +261,35 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ]) + const isAutoAcceptActive = () => { + const sessionID = params.id + if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) + return permission.isAutoAcceptingDirectory(sdk.directory) + } + const permissionCommands = createMemo(() => [ permissionsCommand({ id: "permissions.autoaccept", - title: - params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), + title: isAutoAcceptActive() + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", - disabled: !params.id || !permission.permissionsEnabled(), + disabled: false, onSelect: () => { const sessionID = params.id - if (!sessionID) return - permission.toggleAutoAccept(sessionID, sdk.directory) + if (sessionID) { + permission.toggleAutoAccept(sessionID, sdk.directory) + } else { + permission.toggleAutoAcceptDirectory(sdk.directory) + } + const active = sessionID + ? permission.isAutoAccepting(sessionID, sdk.directory) + : permission.isAutoAcceptingDirectory(sdk.directory) showToast({ - title: permission.isAutoAccepting(sessionID, sdk.directory) + title: active ? language.t("toast.permissions.autoaccept.on.title") : language.t("toast.permissions.autoaccept.off.title"), - description: permission.isAutoAccepting(sessionID, sdk.directory) + description: active ? language.t("toast.permissions.autoaccept.on.description") : language.t("toast.permissions.autoaccept.off.description"), }) 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 74b0ca83ce0..03670250b5f 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,6 +1,5 @@ import type { UserMessage } from "@kilocode/sdk/v2" -import { useLocation, useNavigate } from "@solidjs/router" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" export { messageIdFromHash } from "./message-id-from-hash" @@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void } + autoScroll: { pause: () => void; snapToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: { const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" - const location = useLocation() - const navigate = useNavigate() - const clearMessageHash = () => { - if (!location.hash) return - navigate(location.pathname + location.search, { replace: true }) + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.pathname + window.location.search) } const updateHash = (id: string) => { - navigate(location.pathname + location.search + `#${input.anchor(id)}`, { - replace: true, - }) + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 - const top = Math.max(0, a.top - b.top + root.scrollTop - inset) + const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) + const inset = Number.isNaN(title) ? 0 : title + // With column-reverse, scrollTop is negative — don't clamp to 0 + const top = a.top - b.top + root.scrollTop - inset root.scrollTo({ top, behavior }) return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - console.log({ message, behavior }) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = location.hash.slice(1) + const hash = window.location.hash.slice(1) if (!hash) { - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + + const handler = () => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + } + + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + createEffect(() => { - location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: { } } - if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: { requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) - onMount(() => { - if (typeof window !== "undefined" && "scrollRestoration" in window.history) { - window.history.scrollRestoration = "manual" - } - }) - return { clearMessageHash, scrollToMessage, diff --git a/packages/app/vite.js b/packages/app/vite.js index 5ef808a6839..671bc53cbeb 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,33 +1,6 @@ import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" -import { resolve as resolvePath } from "path" - -// kilocode_change start -const kiloUiDir = resolvePath(fileURLToPath(new URL(".", import.meta.url)), "../kilo-ui").replace(/\\/g, "/") // Normalize to forward slashes for cross-platform compatibility - -/** - * Vite plugin that redirects @opencode-ai/ui imports to @kilocode/kilo-ui, - * but only for importers OUTSIDE of kilo-ui itself. This avoids circular - * resolution when kilo-ui re-exports from @opencode-ai/ui. - * Excludes audio/* and fonts/* which are binary assets. - * @type {import("vite").Plugin} - */ -const kiloUiAlias = { - name: "kilo-ui-alias", - enforce: "pre", - resolveId(source, importer) { - if (!source.startsWith("@opencode-ai/ui")) return - const normalizedImporter = importer?.replace(/\\/g, "/") - if (normalizedImporter?.startsWith(kiloUiDir)) return - const sub = source.replace("@opencode-ai/ui", "") - if (sub.startsWith("/audio/") || sub.startsWith("/fonts/")) return - return this.resolve(source.replace("@opencode-ai/ui", "@kilocode/kilo-ui"), importer, { - skipSelf: true, - }) - }, -} -// kilocode_change end /** * @type {import("vite").PluginOption} @@ -48,7 +21,6 @@ export default [ } }, }, - kiloUiAlias, // kilocode_change tailwindcss(), solidPlugin(), ] diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts index 81158ad244a..be87f94f915 100644 --- a/packages/desktop-electron/src/renderer/i18n/index.ts +++ b/packages/desktop-electron/src/renderer/i18n/index.ts @@ -76,6 +76,7 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue + if (language.toLowerCase().startsWith("en")) return "en" if (language.toLowerCase().startsWith("zh")) { if (language.toLowerCase().includes("hant")) return "zht" return "zh" diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts index 7b1ebfe696a..e1c1e63d970 100644 --- a/packages/desktop/src/i18n/index.ts +++ b/packages/desktop/src/i18n/index.ts @@ -77,6 +77,7 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue + if (language.toLowerCase().startsWith("en")) return "en" if (language.toLowerCase().startsWith("zh")) { if (language.toLowerCase().includes("hant")) return "zht" return "zh" diff --git a/packages/kilo-docs/lib/nav/kiloclaw.ts b/packages/kilo-docs/lib/nav/kiloclaw.ts index bc1ddfc2921..50262d29f6f 100644 --- a/packages/kilo-docs/lib/nav/kiloclaw.ts +++ b/packages/kilo-docs/lib/nav/kiloclaw.ts @@ -12,7 +12,10 @@ export const KiloClawNav: NavSection[] = [ { href: "/kiloclaw/development-tools/github", children: "Development Tools", - subLinks: [{ href: "/kiloclaw/development-tools/github", children: "GitHub" }], + subLinks: [ + { href: "/kiloclaw/development-tools/github", children: "GitHub" }, + { href: "/kiloclaw/development-tools/google", children: "Google Workspace" }, + ], }, { href: "/kiloclaw/version-pinning", children: "Version Pinning" }, { href: "/kiloclaw/troubleshooting", children: "Troubleshooting" }, diff --git a/packages/kilo-docs/pages/kiloclaw/chat-platforms.md b/packages/kilo-docs/pages/kiloclaw/chat-platforms.md index 1e75a6d41c1..25582344c59 100644 --- a/packages/kilo-docs/pages/kiloclaw/chat-platforms.md +++ b/packages/kilo-docs/pages/kiloclaw/chat-platforms.md @@ -30,9 +30,16 @@ You can remove or replace a configured token at any time. To connect Discord, you need a **Bot Token** from the [Discord Developer Portal](https://discord.com/developers/applications). +#### Create an Application and Bot + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and log in +2. Click **New Application**, give it a name, and click **Create** +3. Click **Bot** on the left sidebar +4. Click **Add Bot** and confirm + #### Enable Privileged Intents -Still on the **Bot** page, scroll down to **Privileged Gateway Intents** and enable: +On the **Bot** page, scroll down to **Privileged Gateway Intents** and enable: - **Message Content Intent** (required) - **Server Members Intent** (recommended — needed for role allowlists and name matching) @@ -68,6 +75,14 @@ Enter the token in the Settings tab and click **Save**. You can remove or replac 5. Paste it into your browser, select your server, and click **Continue** 6. You should now see your bot in the Discord server +#### Start Chatting with the Bot + +1. Right-click on the Bot in Discord and click **Message** +2. DM the bot `/start` or `/restart` +3. You should get a response back with a pairing code +4. Return to [app.kilocode.ai/claw](https://app.kilocode.ai/claw) and confirm the pairing code and approve +5. You should now be able to chat with the bot from Discord + ### Slack To connect Slack, you need **both** of the following tokens from [Slack App Management](https://api.slack.com/apps): diff --git a/packages/kilo-docs/pages/kiloclaw/development-tools/google.md b/packages/kilo-docs/pages/kiloclaw/development-tools/google.md new file mode 100644 index 00000000000..0fa7272ec29 --- /dev/null +++ b/packages/kilo-docs/pages/kiloclaw/development-tools/google.md @@ -0,0 +1,88 @@ +--- +title: "Google Workspace Integration" +description: "Connect a dedicated Google account to KiloClaw for access to Gmail, Calendar, Drive, Docs, Sheets, and more" +--- + +# Google Workspace Integration + +Connect a dedicated Google account to KiloClaw so it can interact with Google Workspace services — Gmail, Calendar, Drive, Docs, Sheets, Slides, Tasks, People, Forms, Chat, Classroom, and Apps Script. + +{% callout type="warning" title="Use a standalone Google account" %} +We strongly recommend creating a **dedicated Google account** specifically for KiloClaw rather than connecting your personal Google account. This keeps your personal data separate and gives you full control over what KiloClaw can access. Throughout this guide, "the KiloClaw Google account" refers to this dedicated account. +{% /callout %} + +## What You Get + +Once setup is complete, your KiloClaw machine will have the following configured automatically: + +- The [`gog` CLI](/docs/kiloclaw/pre-installed-software) pre-loaded with the KiloClaw Google account's credentials, giving the agent access to 12+ Google APIs +- Real-time Gmail push notifications via Google Pub/Sub, so KiloClaw can react to incoming emails sent to the dedicated account without polling +- Access to the full range of Google Workspace services: + +| Service | What KiloClaw can do | +| --------------------- | ---------------------------- | +| **Gmail** | Read, draft, and send emails | +| **Google Calendar** | View and manage events | +| **Google Drive** | Access and organize files | +| **Google Docs** | Read and edit documents | +| **Google Sheets** | Read and edit spreadsheets | +| **Google Slides** | Read and edit presentations | +| **Google Tasks** | View and manage tasks | +| **People (Contacts)** | Access contact information | +| **Google Forms** | Read and manage forms | +| **Google Chat** | Send and read messages | +| **Google Classroom** | Access classroom resources | +| **Apps Script** | Manage Apps Script projects | + +## Prerequisites + +Before you begin, make sure you have: + +- **Docker** installed and running on your machine + +## Setup + +1. Go to the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard) +2. Find the **Google Account** section +3. Copy the provided `docker run` command — it includes a short-lived authentication token +4. Paste the command into a terminal on your local machine and run it + +The container launches an interactive setup flow. Follow the on-screen prompts — you will need to switch to a web browser at several points during the process. + +## Using Google Services + +Once setup is complete, KiloClaw can interact with Google Workspace services using the dedicated account. You can issue natural language prompts directly. For example: + +- "Check your Gmail inbox for unread messages" +- "Create a new Google Doc summarizing our meeting notes" +- "Add a meeting to your calendar for tomorrow at 2pm" +- "List recent files in your Google Drive" + +KiloClaw will automatically use the dedicated account's credentials to fulfill these requests. + +### Accessing your personal Google data + +KiloClaw's credentials are tied to its dedicated Google account — not your personal one. To let KiloClaw work with your personal Google data, you need to **share or delegate access from your personal account to the KiloClaw account**: + +| Service | How to share access | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Google Calendar** | Share your calendar with the KiloClaw account's email address ([instructions](https://support.google.com/calendar/answer/37082)) | +| **Google Drive** | Share specific files or folders with the KiloClaw account's email address | +| **Gmail** (Option 1: Delegation) | Set up [Gmail delegation](https://support.google.com/mail/answer/138350) to grant KiloClaw read and write access to your inbox — it can read, draft, and send emails on your behalf | +| **Gmail** (Option 2: Forwarding) | Set up [email forwarding](https://support.google.com/mail/answer/10957) so KiloClaw receives its own copy of all incoming emails — it can read them but cannot make any changes to your original inbox | +| **Google Docs / Sheets / Slides** | Share individual documents with the KiloClaw account's email address | + +Once access is shared, reference the delegation in your prompts so KiloClaw knows where to look: + +- "Check the shared calendar from alice@example.com for tomorrow's meetings" +- "Open the Q3 report shared with you from the team Drive" +- "Read the latest emails in the delegated inbox from alice@example.com" +- "Draft a reply in the delegated Gmail from alice@example.com to the last message from Bob" + +## Related + +- [KiloClaw Overview](/docs/kiloclaw/overview) +- [Dashboard Reference](/docs/kiloclaw/dashboard) +- [GitHub Integration](/docs/kiloclaw/development-tools/github) +- [Pre-installed Software](/docs/kiloclaw/pre-installed-software) +- [Chat Platforms](/docs/kiloclaw/chat-platforms) diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index ecb848ca28d..3bf481a77d4 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -93,7 +93,7 @@ "solid-js": "catalog:" }, "devDependencies": { - "@playwright/test": "1.58.2", + "@playwright/test": "1.57.0", "@storybook/addon-a11y": "10.2.10", "@storybook/addon-docs": "10.2.10", "@storybook/addon-themes": "10.2.10", @@ -117,6 +117,7 @@ "@pierre/diffs": "catalog:", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", - "lucide-solid": "0.576.0" + "lucide-solid": "0.576.0", + "strip-ansi": "7.1.2" } } diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index bd6cb7bcfdc..a96af21fe11 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -1,7 +1,7 @@ import { sampledChecksum } from "@opencode-ai/util/encode" import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" -import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "@opencode-ai/ui/pierre" import { acquireVirtualizer, virtualMetrics } from "@opencode-ai/ui/pierre/virtualizer" import { getWorkerPool } from "@opencode-ai/ui/pierre/worker" @@ -545,7 +545,6 @@ export function Diff(props: DiffProps) { const opts = options() const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) const virtualizer = getVirtualizer() - const annotations = local.annotations const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" @@ -572,7 +571,7 @@ export function Diff(props: DiffProps) { contents: afterContents, cacheKey: cacheKey(afterContents), }, - lineAnnotations: annotations, + lineAnnotations: untrack(() => local.annotations), containerWrapper: container, }) @@ -582,6 +581,23 @@ export function Diff(props: DiffProps) { notifyRendered() }) + // Separate effect for annotation-only updates. When annotations change but + // file contents / options stay the same, this avoids the full teardown+rebuild + // in the render effect above. Pierre's setLineAnnotations + rerender handles + // incremental DOM patching of annotation slots. + // defer: true skips the initial run (the main effect already passed annotations). + createEffect( + on( + () => local.annotations, + (annotations) => { + if (!instance) return + instance.setLineAnnotations(annotations ?? []) + instance.rerender() + }, + { defer: true }, + ), + ) + createEffect(() => { if (typeof document === "undefined") return if (typeof MutationObserver === "undefined") return diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index a9ef66d2b01..e731aed1367 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1,25 +1,1210 @@ -export * from "@opencode-ai/ui/message-part" +import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" +import stripAnsi from "strip-ansi" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import { + AgentPart, + AssistantMessage, + FilePart, + Message as MessageType, + Part as PartType, + ReasoningPart, + TextPart, + ToolPart, + UserMessage, + Todo, + QuestionAnswer, + QuestionInfo, +} from "@kilocode/sdk/v2" +import { useData } from "../context" +import { useFileComponent } from "../context/file" +import { useDialog } from "../context/dialog" +import { type UiI18n, useI18n } from "../context/i18n" +import { GenericTool, ToolCall } from "./basic-tool" +import { Accordion } from "./accordion" +import { StickyAccordionHeader } from "./sticky-accordion-header" +import { Card } from "./card" +import { Collapsible } from "./collapsible" +import { FileIcon } from "./file-icon" +import { Icon } from "./icon" +import { Checkbox } from "./checkbox" +import { DiffChanges } from "./diff-changes" +import { Markdown } from "./markdown" +import { ImagePreview } from "./image-preview" +import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" +import { checksum } from "@opencode-ai/util/encode" +import { Tooltip } from "./tooltip" +import { IconButton } from "./icon-button" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" +import { list } from "@opencode-ai/ui/text-utils" +import { GrowBox } from "@opencode-ai/ui/grow-box" +import { COLLAPSIBLE_SPRING } from "@opencode-ai/ui/motion" +import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "@opencode-ai/ui/tool-utils" +import { + ContextToolGroupHeader, + ContextToolExpandedList, + ContextToolRollingResults, +} from "@opencode-ai/ui/context-tool-results" +import { ShellRollingResults } from "@opencode-ai/ui/shell-rolling-results" +import { pageVisible } from "@opencode-ai/ui/hooks" +import { extractFilePathFromHref } from "../file-path" -import { Show, createSignal } from "solid-js" -import { PART_MAPPING } from "@opencode-ai/ui/message-part" -import { Collapsible } from "@opencode-ai/ui/collapsible" -import { Icon } from "@opencode-ai/ui/icon" -import { Markdown } from "@opencode-ai/ui/markdown" -import { useI18n } from "@opencode-ai/ui/context/i18n" -import type { ReasoningPart } from "@kilocode/sdk/v2" -import type { MessagePartProps } from "@opencode-ai/ui/message-part" +// Windows CLI tools (e.g. winget) use \r to overwrite progress bars in-place. +// Without this, every progress frame renders as a separate visual line. +function processCarriageReturns(input: string): string { + // Normalize \r\n to \n first so CRLF line endings aren't treated as overwrites + const normalized = input.replace(/\r\n/g, "\n") + return normalized + .split("\n") + .map((line) => { + if (!line.includes("\r")) return line + const parts = line.split("\r") + // A trailing \r produces an empty last segment — preserve the previous visible frame + const last = parts[parts.length - 1] + if (last !== "") return last + for (let i = parts.length - 2; i >= 0; i--) { + if (parts[i] !== "") return parts[i] + } + return "" + }) + .join("\n") +} -// Override: collapsible reasoning block with brain icon + label -// Replaces the upstream flat markdown render with a clearly distinguished, collapsed-by-default block +interface Diagnostic { + range: { + start: { line: number; character: number } + end: { line: number; character: number } + } + message: string + severity?: number +} + +function getDiagnostics( + diagnosticsByFile: Record | undefined, + filePath: string | undefined, +): Diagnostic[] { + if (!diagnosticsByFile || !filePath) return [] + const diagnostics = diagnosticsByFile[filePath] ?? [] + return diagnostics.filter((d) => d.severity === 1).slice(0, 3) +} + +function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { + const i18n = useI18n() + return ( + 0}> +
+ + {(diagnostic) => ( +
+ {i18n.t("ui.messagePart.diagnostic.error")} + + [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] + + {diagnostic.message} +
+ )} +
+
+
+ ) +} + +export interface MessagePartProps { + part: PartType + message: MessageType + hideDetails?: boolean + defaultOpen?: boolean + showAssistantCopyPartID?: string | null + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element + animate?: boolean + working?: boolean +} + +export type PartComponent = Component + +export const PART_MAPPING: Record = {} + +function relativizeProjectPath(path: string, directory?: string) { + if (!path) return "" + if (!directory) return path + if (directory === "/") return path + if (directory === "\\") return path + if (path === directory) return "" + + const separator = directory.includes("\\") ? "\\" : "/" + const prefix = directory.endsWith(separator) ? directory : directory + separator + if (!path.startsWith(prefix)) return path + return path.slice(directory.length) +} + +function getDirectory(path: string | undefined) { + const data = useData() + return relativizeProjectPath(_getDirectory(path), data.directory) +} + +import type { IconProps } from "./icon" + +export type ToolInfo = { + icon: IconProps["name"] + title: string + subtitle?: string +} + +function agentTitle(i18n: UiI18n, type?: string) { + if (!type) return i18n.t("ui.tool.agent.default") + return i18n.t("ui.tool.agent", { type }) +} + +export function getToolInfo(tool: string, input: any = {}): ToolInfo { + const i18n = useI18n() + switch (tool) { + case "read": + return { + icon: "glasses", + title: i18n.t("ui.tool.read"), + subtitle: input.filePath ? getFilename(input.filePath) : undefined, + } + case "list": + return { + icon: "bullet-list", + title: i18n.t("ui.tool.list"), + subtitle: input.path ? getFilename(input.path) : undefined, + } + case "glob": + return { + icon: "magnifying-glass-menu", + title: i18n.t("ui.tool.glob"), + subtitle: input.pattern, + } + case "grep": + return { + icon: "magnifying-glass-menu", + title: i18n.t("ui.tool.grep"), + subtitle: input.pattern, + } + case "webfetch": + return { + icon: "window-cursor", + title: i18n.t("ui.tool.webfetch"), + subtitle: input.url, + } + case "websearch": + return { + icon: "window-cursor", + title: i18n.t("ui.tool.websearch"), + subtitle: input.query, + } + case "codesearch": + return { + icon: "code", + title: i18n.t("ui.tool.codesearch"), + subtitle: input.query, + } + case "task": { + const type = + typeof input.subagent_type === "string" && input.subagent_type + ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1) + : undefined + return { + icon: "task", + title: agentTitle(i18n, type), + subtitle: input.description, + } + } + case "bash": + return { + icon: "console", + title: i18n.t("ui.tool.shell"), + subtitle: input.description, + } + case "edit": + return { + icon: "code-lines", + title: i18n.t("ui.messagePart.title.edit"), + subtitle: input.filePath ? getFilename(input.filePath) : undefined, + } + case "write": + return { + icon: "code-lines", + title: i18n.t("ui.messagePart.title.write"), + subtitle: input.filePath ? getFilename(input.filePath) : undefined, + } + case "apply_patch": + return { + icon: "code-lines", + title: i18n.t("ui.tool.patch"), + subtitle: input.files?.length + ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + : undefined, + } + case "todowrite": + return { + icon: "checklist", + title: i18n.t("ui.tool.todos"), + } + case "todoread": + return { + icon: "checklist", + title: i18n.t("ui.tool.todos.read"), + } + case "question": + return { + icon: "bubble-5", + title: i18n.t("ui.tool.questions"), + } + case "skill": + return { + icon: "brain", + title: i18n.t("ui.tool.skill"), + subtitle: typeof input.name === "string" ? input.name : undefined, + } + default: + return { + icon: "mcp", + title: tool, + } + } +} + +function urls(text: string | undefined) { + if (!text) return [] + const seen = new Set() + return [...text.matchAll(/https?:\/\/[^\s<>"'`)\]]+/g)] + .map((item) => item[0].replace(/[),.;:!?]+$/g, "")) + .filter((item) => { + if (seen.has(item)) return false + seen.add(item) + return true + }) +} + +const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) +const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) + +function createGroupOpenState() { + const [state, setState] = createStore>({}) + const read = (key?: string, collapse?: boolean) => { + if (!key) return true + const value = state[key] + if (value !== undefined) return value + return !collapse + } + const controlled = (key?: string) => { + if (!key) return false + return state[key] !== undefined + } + const write = (key: string, value: boolean) => { + setState(key, value) + } + return { read, controlled, write } +} + +function shouldCollapseGroup( + statuses: (string | undefined)[], + opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, +) { + if (opts.afterTool) return true + if (opts.groupTail === false) return true + if (!pageVisible()) return false + if (opts.working) return false + if (!statuses.length) return false + return !statuses.some((s) => busy(s)) +} + +function renderable(part: PartType, showReasoningSummaries = true) { + if (part.type === "tool") { + if (HIDDEN_TOOLS.has(part.tool)) return false + if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" + return true + } + if (part.type === "text") return !!part.text?.trim() + if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim() + return !!PART_MAPPING[part.type] +} + +function toolDefaultOpen(tool: string, shell = false, edit = false) { + if (tool === "bash") return shell + if (tool === "edit" || tool === "write") return edit + if (tool === "apply_patch") return false +} + +function partDefaultOpen(part: PartType, shell = false, edit = false) { + if (part.type !== "tool") return + return toolDefaultOpen(part.tool, shell, edit) +} + +function PartGrow(props: { + children: JSX.Element + animate?: boolean + animateToggle?: boolean + gap?: number + fade?: boolean + edge?: boolean + edgeHeight?: number + edgeOpacity?: number + edgeIdle?: number + edgeFade?: number + edgeRise?: number + grow?: boolean + watch?: boolean + open?: boolean + spring?: import("@opencode-ai/ui/motion").SpringConfig + toggleSpring?: import("@opencode-ai/ui/motion").SpringConfig +}) { + return ( + + {props.children} + + ) +} + +export function AssistantParts(props: { + messages: AssistantMessage[] + showAssistantCopyPartID?: string | null + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element + working?: boolean + showReasoningSummaries?: boolean + shellToolDefaultOpen?: boolean + editToolDefaultOpen?: boolean + animate?: boolean +}) { + const data = useData() + const emptyParts: PartType[] = [] + const groupState = createGroupOpenState() + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record< + string, + | { + type: "part" + part: PartType + message: AssistantMessage + context?: boolean + groupKey?: string + afterTool?: boolean + groupTail?: boolean + groupParts?: { part: ToolPart; message: AssistantMessage }[] + } + | { + type: "context" + groupKey: string + parts: { part: ToolPart; message: AssistantMessage }[] + tail: boolean + afterTool: boolean + } + > = {} + const push = (key: string, item: (typeof items)[string]) => { + keys.push(key) + items[key] = item + } + const id = (part: PartType) => { + if (part.type === "tool") return part.callID || part.id + return part.id + } + const parts = props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ message, part })), + ) + + let start = -1 + + const flush = (end: number, tail: boolean, afterTool: boolean) => { + if (start < 0) return + const group = parts + .slice(start, end + 1) + .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) + if (!group.length) { + start = -1 + return + } + const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` + push(groupKey, { + type: "context", + groupKey, + parts: group, + tail, + afterTool, + }) + group.forEach((entry) => { + push(`part:${entry.message.id}:${id(entry.part)}`, { + type: "part", + part: entry.part, + message: entry.message, + context: true, + groupKey, + afterTool, + groupTail: tail, + groupParts: group, + }) + }) + start = -1 + } + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1, false, (item as { part: PartType }).part.type === "tool") + push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) + }) + + flush(parts.length - 1, true, false) + return { keys, items } + }) + + const last = createMemo(() => grouped().keys.at(-1)) + + return ( +
+ + {(key) => { + const item = createMemo(() => grouped().items[key]) + const ctx = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "context") return + return value + }) + const part = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "part") return + return value + }) + const tail = createMemo(() => last() === key) + const tool = createMemo(() => { + const value = part() + if (!value) return false + return value.part.type === "tool" + }) + const context = createMemo(() => !!part()?.context) + const contextSpring = createMemo(() => { + const entry = part() + if (!entry?.context) return undefined + if (!groupState.controlled(entry.groupKey)) return undefined + return COLLAPSIBLE_SPRING + }) + const contextOpen = createMemo(() => { + const collapse = ( + afterTool?: boolean, + groupTail?: boolean, + group?: { part: ToolPart; message: AssistantMessage }[], + ) => + shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { + afterTool, + groupTail, + working: props.working, + }) + const value = ctx() + if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) + const entry = part() + return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) + }) + const visible = createMemo(() => { + if (!context()) return true + if (ctx()) return true + return false + }) + + const turnSummary = createMemo(() => { + const value = part() + if (!value) return false + if (value.part.type !== "text") return false + if (!props.showTurnDiffSummary) return false + return props.showAssistantCopyPartID === value.part.id + }) + const fade = createMemo(() => { + if (ctx()) return true + return tool() + }) + const edge = createMemo(() => { + const entry = part() + if (!entry) return false + if (entry.part.type !== "text") return false + if (!props.working) return false + return tail() + }) + const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) + const ctxPartsCache = new Map() + let ctxPartsPrev: ToolPart[] = [] + const ctxParts = createMemo(() => { + const parts = ctx()?.parts ?? [] + if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev + const result: ToolPart[] = [] + for (const item of parts) { + const k = item.part.callID || item.part.id + const cached = ctxPartsCache.get(k) + if (cached) { + result.push(cached) + } else { + ctxPartsCache.set(k, item.part) + result.push(item.part) + } + } + ctxPartsPrev = result + return result + }) + const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) + const ctxPending = ctxPendingRaw + const ctxHoldOpen = hold(ctxPendingRaw) + const shell = createMemo(() => { + const value = part() + if (!value) return + if (value.part.type !== "tool") return + if (value.part.tool !== "bash") return + return value.part + }) + const kind = createMemo(() => { + if (ctx()) return "context" + if (shell()) return "shell" + const value = part() + if (!value) return "part" + return value.part.type + }) + const shown = createMemo(() => { + if (ctx()) return true + if (shell()) return true + const entry = part() + if (!entry) return false + return !entry.context + }) + const partGrowProps = () => ({ + animate: props.animate, + gap: 0, + fade: fade(), + edge: edge(), + edgeHeight: 20, + edgeOpacity: 0.95, + edgeIdle: 100, + edgeFade: 0.6, + edgeRise: 0.1, + grow: true, + watch: watch(), + animateToggle: true, + open: visible(), + toggleSpring: contextSpring(), + }) + return ( + +
+ + {(entry) => ( + <> + + groupState.write(entry().groupKey, value)} + /> + + + + + )} + + {(value) => } + + {(entry) => ( + + +
+ +
+
+
+ )} +
+
+
+ ) + }} +
+
+ ) +} + +function isContextGroupTool(part: PartType): part is ToolPart { + return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) +} + +function ExaOutput(props: { output?: string }) { + const links = createMemo(() => urls(props.output)) + + return ( + 0}> +
+
+ + {(url) => ( + event.stopPropagation()} + > + {url} + + )} + +
+
+
+ ) +} + +export function registerPartComponent(type: string, component: PartComponent) { + PART_MAPPING[type] = component +} + +export function UserMessageDisplay(props: { + message: UserMessage + parts: PartType[] + interrupted?: boolean + animate?: boolean + queued?: boolean + onRevert?: () => void +}) { + const data = useData() + const dialog = useDialog() + const i18n = useI18n() + const [copied, setCopied] = createSignal(false) + + const textPart = createMemo( + () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, + ) + + const text = createMemo(() => textPart()?.text || "") + + const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) + + const attachments = createMemo(() => + files()?.filter((f) => { + const mime = f.mime + return mime.startsWith("image/") || mime === "application/pdf" + }), + ) + + const inlineFiles = createMemo(() => + files().filter((f) => { + const mime = f.mime + return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined + }), + ) + + const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) + + const model = createMemo(() => { + const providerID = props.message.model?.providerID + const modelID = props.message.model?.modelID + if (!providerID || !modelID) return "" + const match = data.store.provider?.all?.find((p) => p.id === providerID) + return match?.models?.[modelID]?.name ?? modelID + }) + + const stamp = createMemo(() => { + const created = props.message.time?.created + if (typeof created !== "number") return "" + const date = new Date(created) + const hours = date.getHours() + const hour12 = hours % 12 || 12 + const minute = String(date.getMinutes()).padStart(2, "0") + return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` + }) + + const metaHead = createMemo(() => { + const agent = props.message.agent + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] + return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") + }) + + const metaTail = createMemo(() => { + const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] + return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") + }) + + const openImagePreview = (url: string, alt?: string) => { + dialog.show(() => ) + } + + const handleCopy = async () => { + const content = text() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + +
+
+ 0}> +
+ + {(file) => ( +
{ + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + + +
+ } + > + {file.filename + +
+ )} + +
+ + + <> +
+
+ +
+ +
+ +
+
+
+ +
+ + + + + {metaHead()} + + + + + {"\u00A0\u00B7\u00A0"} + + + + + {metaTail()} + + + + + + + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + props.onRevert?.() + }} + aria-label={i18n.t("ui.message.revert")} + /> + + + + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+ +
+
+
+ + ) +} + +type HighlightSegment = { text: string; type?: "file" | "agent" } + +function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) { + const segments = createMemo(() => { + const text = props.text + + const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ + ...props.references + .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) + .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), + ...props.agents + .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) + .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), + ].sort((a, b) => a.start - b.start) + + const result: HighlightSegment[] = [] + let lastIndex = 0 + + for (const ref of allRefs) { + if (ref.start < lastIndex) continue + + if (ref.start > lastIndex) { + result.push({ text: text.slice(lastIndex, ref.start) }) + } + + result.push({ text: text.slice(ref.start, ref.end), type: ref.type }) + lastIndex = ref.end + } + + if (lastIndex < text.length) { + result.push({ text: text.slice(lastIndex) }) + } + + return result + }) + + return {(segment) => {segment.text}} +} + +export function Part(props: MessagePartProps) { + const component = createMemo(() => PART_MAPPING[props.part.type]) + return ( + + + + ) +} + +export interface ToolProps { + input: Record + metadata: Record + tool: string + partID?: string + callID?: string + output?: string + status?: string + hideDetails?: boolean + defaultOpen?: boolean + forceOpen?: boolean + locked?: boolean + animate?: boolean + reveal?: boolean +} + +export type ToolComponent = Component + +const state: Record< + string, + { + name: string + render?: ToolComponent + } +> = {} + +export function registerTool(input: { name: string; render?: ToolComponent }) { + state[input.name] = input + return input +} + +export function getTool(name: string) { + return state[name]?.render +} + +export const ToolRegistry = { + register: registerTool, + render: getTool, +} + +function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { + const value = createMemo(() => props.path || "tool-file") + + return ( + + + + +
+
+ +
+ + {`\u202A${getDirectory(props.path)}\u202C`} + + {getFilename(props.path)} +
+
+
+ {props.actions} + +
+
+
+
+ {props.children} +
+
+ ) +} + +// GenericTool (upstream) does not render output; this override does. +// When hideDetails is true, render as a row (no content), otherwise as a panel with markdown output. +function McpTool(props: ToolProps) { + return ( + } + > + + + {(output) => ( +
+ +
+ )} +
+
+
+ ) +} + +PART_MAPPING["tool"] = function ToolPartDisplay(props) { + const i18n = useI18n() + const part = props.part as ToolPart + const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) + + const emptyInput: Record = {} + const emptyMetadata: Record = {} + + const input = () => part.state?.input ?? emptyInput + // @ts-expect-error + const partMetadata = () => part.state?.metadata ?? emptyMetadata + + const render = createMemo(() => ToolRegistry.render(part.tool) ?? McpTool) + + return ( + +
+ + + {(error) => { + const cleaned = error().replace("Error: ", "") + if (part.tool === "question" && cleaned.includes("dismissed this question")) { + return ( +
+ + {i18n.t("ui.messagePart.questions.dismissed")} + +
+ ) + } + const [title, ...rest] = cleaned.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ + {cleaned} + +
+
+
+ ) + }} +
+ + + +
+
+
+ ) +} + +PART_MAPPING["compaction"] = function CompactionPartDisplay() { + const i18n = useI18n() + return ( +
+
+ + + {i18n.t("ui.messagePart.compaction")} + + +
+
+ ) +} + +PART_MAPPING["text"] = function TextPartDisplay(props) { + const data = useData() + const part = () => props.part as TextPart + + const displayText = () => (part().text ?? "").trim() + const throttledText = createThrottledValue(displayText) + const summary = createMemo(() => { + if (props.message.role !== "assistant") return + if (!props.showTurnDiffSummary) return + if (props.showAssistantCopyPartID !== part().id) return + return props.turnDiffSummary + }) + + const handleMarkdownClick = (e: MouseEvent) => { + if (!data.openFile) return + const target = e.target + if (!(target instanceof HTMLElement)) return + // Handle .file-link code spans (e.g. `src/foo.ts:42`) + const fileLink = target.closest(".file-link[data-file-path]") + if (fileLink) { + const path = fileLink.getAttribute("data-file-path") + if (!path) return + const lineAttr = fileLink.getAttribute("data-file-line") + const colAttr = fileLink.getAttribute("data-file-col") + const line = lineAttr ? parseInt(lineAttr, 10) : undefined + const column = colAttr ? parseInt(colAttr, 10) : undefined + data.openFile(path, line, column) + return + } + // Handle markdown links whose href looks like a relative file path + const anchor = target.closest("a.external-link") as HTMLAnchorElement | null + if (anchor) { + const href = anchor.getAttribute("href") + if (!href) return + const filePath = extractFilePathFromHref(href) + if (!filePath) return + e.preventDefault() + data.openFile(filePath) + } + } + + return ( + +
+
+ +
+ + {(render) => ( + +
{render()()}
+
+ )} +
+
+
+ ) +} + +// Overrides upstream flat markdown render with a clearly distinguished, collapsed-by-default block. +// Also filters encrypted reasoning data from OpenRouter that appears as [REDACTED]. PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProps) { const part = props.part as unknown as ReasoningPart const i18n = useI18n() const [open, setOpen] = createSignal(false) - // Filter out redacted reasoning chunks from OpenRouter (encrypted reasoning data appears as [REDACTED]) const text = () => (part.text ?? "").replace("[REDACTED]", "").trim() + const throttledText = createThrottledValue(text) return ( - +
@@ -31,7 +1216,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
- +
@@ -39,3 +1224,1078 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp ) } + +function useToolReveal(pending: () => boolean, animate?: () => boolean) { + const enabled = () => animate?.() ?? true + const [live, setLive] = createSignal(pending() || enabled()) + createEffect(() => { + if (pending()) setLive(true) + }) + return () => enabled() && live() +} + +function WebfetchMeta(props: { url: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + event.stopPropagation()} + > + {props.url} + +
+ +
+
+ ) +} + +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolText(props: { text: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolLoadedFile(props: { text: string; animate?: boolean; onClick?: () => void }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) + + return ( + +
+ + {props.text} +
+
+ ) +} + +function ToolTriggerRow(props: { + title: string + pending: boolean + subtitle?: string + args?: string[] + action?: JSX.Element + animate?: boolean + revealOnMount?: boolean +}) { + const reveal = useToolReveal( + () => props.pending, + () => props.animate !== false, + ) + const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) + const detailAnimate = createMemo(() => { + if (props.animate === false) return false + if (props.revealOnMount) return true + if (!props.pending && !reveal()) return true + return reveal() + }) + + return ( +
+
+ + + + {(text) => } +
+ {props.action} +
+ ) +} + +type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] + +function ToolMetaLine(props: { + filename: string + path?: string + changes?: DiffValue + delay?: number + animate?: boolean + soft?: boolean + onClick?: (e: MouseEvent) => void +}) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) + + return ( + + {props.filename} + + {props.path} + + {(changes) => } + + ) +} + +function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.04, animate: props.animate }) + + return ( +
+ +
+ ) +} + +function ShellText(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + + + {props.text} + + + + ) +} + +ToolRegistry.register({ + name: "read", + render(props) { + const data = useData() + const i18n = useI18n() + const args: string[] = [] + if (props.input.offset) args.push("offset=" + props.input.offset) + if (props.input.limit) args.push("limit=" + props.input.limit) + const loaded = createMemo(() => { + const value = props.metadata.loaded + if (!value || !Array.isArray(value)) return [] + return value.filter((p): p is string => typeof p === "string") + }) + const pending = createMemo(() => busy(props.status)) + return ( + <> + data.openFile!(props.input.filePath) : undefined + } + trigger={ + + } + /> + + {(filepath) => ( + data.openFile!(filepath) : undefined} + /> + )} + + + ) + }, +}) + +ToolRegistry.register({ + name: "list", + render(props) { + const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + return ( + + } + > + + {(output) => ( +
+ +
+ )} +
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "glob", + render(props) { + const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + return ( + + +
+ )} +
+ + ) + }, +}) + +ToolRegistry.register({ + name: "grep", + render(props) { + const i18n = useI18n() + const args: string[] = [] + if (props.input.pattern) args.push("pattern=" + props.input.pattern) + if (props.input.include) args.push("include=" + props.input.include) + const pending = createMemo(() => busy(props.status)) + return ( + + } + > + + {(output) => ( +
+ +
+ )} +
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "webfetch", + render(props) { + const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) + const url = createMemo(() => { + const value = props.input.url + if (typeof value !== "string") return "" + return value + }) + return ( + +
+ + + + {(value) => } +
+
+ } + /> + ) + }, +}) + +ToolRegistry.register({ + name: "websearch", + render(props) { + const i18n = useI18n() + const query = createMemo(() => { + const value = props.input.query + if (typeof value !== "string") return "" + return value + }) + + return ( + + + + ) + }, +}) + +ToolRegistry.register({ + name: "codesearch", + render(props) { + const i18n = useI18n() + const query = createMemo(() => { + const value = props.input.query + if (typeof value !== "string") return "" + return value + }) + + return ( + + + + ) + }, +}) + +ToolRegistry.register({ + name: "task", + render(props) { + const data = useData() + const i18n = useI18n() + const childSessionId = () => props.metadata.sessionId as string | undefined + const type = createMemo(() => { + const raw = props.input.subagent_type + if (typeof raw !== "string" || !raw) return undefined + return raw[0]!.toUpperCase() + raw.slice(1) + }) + const title = createMemo(() => agentTitle(i18n, type())) + const description = createMemo(() => { + const value = props.input.description + if (typeof value === "string") return value + return undefined + }) + const running = createMemo(() => busy(props.status)) + const reveal = useToolReveal(running, () => props.reveal !== false) + + const href = createMemo(() => { + const sessionId = childSessionId() + if (!sessionId) return + + const direct = data.sessionHref?.(sessionId) + if (direct) return direct + + if (typeof window === "undefined") return + const path = window.location.pathname + const idx = path.indexOf("/session") + if (idx === -1) return + return `${path.slice(0, idx)}/session/${sessionId}` + }) + + const handleLinkClick = (e: MouseEvent) => { + const sessionId = childSessionId() + const url = href() + if (!sessionId || !url) return + + e.stopPropagation() + + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const nav = data.navigateToSession + if (!nav || typeof window === "undefined") return + + e.preventDefault() + const before = window.location.pathname + window.location.search + window.location.hash + nav(sessionId) + setTimeout(() => { + const after = window.location.pathname + window.location.search + window.location.hash + if (after === before) window.location.assign(url) + }, 50) + } + + const trigger = () => ( +
+
+ + + + + + + {(url) => ( + + )} + + + + + + +
+
+ ) + + return + }, +}) + +ToolRegistry.register({ + name: "bash", + render(props) { + const i18n = useI18n() + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) + const subtitle = () => props.input.description ?? props.metadata.description + + // also apply processCarriageReturns for Windows CLI tools + const cmd = createMemo(() => { + const value = props.input.command ?? props.metadata.command + if (typeof value === "string") return value + return "" + }) + const rawOutput = createMemo(() => { + if (typeof props.output === "string") return props.output + if (typeof props.metadata.output === "string") return props.metadata.output + return "" + }) + const out = createMemo(() => processCarriageReturns(stripAnsi(rawOutput()))) + const text = createMemo(() => `$ ${cmd()}${out() ? "\n\n" + out() : ""}`) + + const hasOutput = createMemo(() => out().length > 0) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const command = cmd() + if (!command) return + const content = out() ? `${command}\n\n${out()}` : command + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + +
+ + + + {(text) => } +
+
+ } + > +
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+
+              {text()}
+            
+
+
+ + ) + }, +}) + +ToolRegistry.register({ + name: "edit", + render(props) { + const data = useData() + const i18n = useI18n() + const fileComponent = useFileComponent() + const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) + const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") + const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) + + const handleFileClick = (e: MouseEvent) => { + if (!data.openFile || !props.input.filePath) return + e.stopPropagation() + data.openFile(props.input.filePath) + } + + return ( +
+ +
+
+ + + + + {(name) => ( + + )} + +
+
+
+ } + > + + + {(diff) => } + + } + > +
+ +
+ +
+ + +
+ ) + }, +}) + +ToolRegistry.register({ + name: "write", + render(props) { + const data = useData() + const i18n = useI18n() + const fileComponent = useFileComponent() + const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) + const path = createMemo(() => props.input.filePath || "") + const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) + + const handleFileClick = (e: MouseEvent) => { + if (!data.openFile || !props.input.filePath) return + e.stopPropagation() + data.openFile(props.input.filePath) + } + + return ( +
+ +
+
+ + + + + {(name) => ( + + )} + +
+
+
+ } + > + + +
+ +
+
+
+ + +
+ ) + }, +}) + +interface ApplyPatchFile { + filePath: string + relativePath: string + type: "add" | "update" | "delete" | "move" + diff: string + before: string + after: string + additions: number + deletions: number + movePath?: string +} + +ToolRegistry.register({ + name: "apply_patch", + render(props) { + const data = useData() + const i18n = useI18n() + const fileComponent = useFileComponent() + const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) + const single = createMemo(() => { + const list = files() + if (list.length !== 1) return + return list[0] + }) + const [expanded, setExpanded] = createSignal([]) + let seeded = false + createEffect(() => { + const list = files() + if (list.length === 0) return + if (seeded) return + seeded = true + setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) + }) + const subtitle = createMemo(() => { + const count = files().length + if (count === 0) return "" + return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + }) + + return ( +
+ +
+
+ + + + + {(file) => ( + { + e.stopPropagation() + data.openFile!(file().filePath) + } + : undefined + } + /> + )} + + {(text) => } +
+
+
+ } + > + 0}> + setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(file) => { + const active = createMemo(() => expanded().includes(file.filePath)) + const [visible, setVisible] = createSignal(false) + createEffect(() => { + if (!active()) { + setVisible(false) + return + } + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }) + + return ( + + + +
+
+ +
+ + {`\u202A${getDirectory(file.relativePath)}\u202C`} + + + { + if (!data.openFile) return + e.stopPropagation() + data.openFile(file.filePath) + }} + > + {getFilename(file.relativePath)} + +
+
+
+ + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
+
+ } + > + {(file) => ( + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + } + > +
+ +
+
+ )} + + +
+ ) + }, +}) + +ToolRegistry.register({ + name: "todowrite", + render(props) { + const i18n = useI18n() + const todos = createMemo(() => { + const meta = props.metadata?.todos + if (Array.isArray(meta)) return meta + + const input = props.input.todos + if (Array.isArray(input)) return input + + return [] + }) + const pending = createMemo(() => busy(props.status)) + + const subtitle = createMemo(() => { + const list = todos() + if (list.length === 0) return "" + return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}` + }) + + return ( + + } + > + +
+ + {(todo: Todo) => ( + + + {todo.content} + + + )} + +
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "question", + render(props) { + const i18n = useI18n() + const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) + const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) + const completed = createMemo(() => answers().length > 0) + const pending = createMemo(() => busy(props.status)) + + const subtitle = createMemo(() => { + const count = questions().length + if (count === 0) return "" + if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) + return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` + }) + + return ( + + } + > + +
+ + {(q, i) => { + const answer = () => answers()[i()] ?? [] + return ( +
+
{q.question}
+
{answer().join(", ") || i18n.t("ui.question.answer.none")}
+
+ ) + }} +
+
+
+
+ ) + }, +}) + +ToolRegistry.register({ + name: "skill", + render(props) { + const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> + ) + }, +}) diff --git a/packages/kilo-ui/src/file-path.ts b/packages/kilo-ui/src/file-path.ts new file mode 100644 index 00000000000..e612dfd75ad --- /dev/null +++ b/packages/kilo-ui/src/file-path.ts @@ -0,0 +1,68 @@ +// Matches text that looks like a file path: +// - Unix: /foo/bar.ts, ./foo.ts, ../foo.ts, foo.ts +// - Windows drive: C:\foo\bar.ts, C:/foo/bar.ts +// - Windows UNC: \\server\share\file.ts +// Supports optional :line or :line:col suffix. +const FILE_PATH_UNIX_RE = + /^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ +const FILE_PATH_WIN_RE = /^((?:[a-zA-Z]:[/\\]|\\\\)(?:[^\\/]+[/\\])*[^\\/]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ + +/** + * Parse an inline code span into a file path with optional line/column. + * Returns undefined when the text does not look like a file reference. + * + * Handles Unix paths (`/foo/bar.ts`, `./foo.ts`, `foo.ts`), + * Windows drive paths (`C:\foo\bar.ts`), and UNC paths (`\\server\share\file.ts`). + */ +export function parseFilePath(text: string): { path: string; line?: number; column?: number } | undefined { + if (text.includes("://")) return undefined + if (text.includes(" ")) return undefined + const match = FILE_PATH_UNIX_RE.exec(text) ?? FILE_PATH_WIN_RE.exec(text) + if (!match) return undefined + return { + path: match[1], + line: match[2] ? parseInt(match[2], 10) : undefined, + column: match[3] ? parseInt(match[3], 10) : undefined, + } +} + +const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ + +/** + * Extract a file path from a markdown link href, or return undefined + * when the href is a URL, anchor, scheme, or otherwise not a file reference. + * + * Strips `#fragment` and `?query` suffixes before returning the path. + */ +export function extractFilePathFromHref(href: string): string | undefined { + if (!href) return undefined + // Handle file:// URLs — extract the path component and decode it + if (href.startsWith("file://")) { + try { + const url = new URL(href) + const decoded = decodeURIComponent(url.pathname) + if (!decoded) return undefined + // On Windows, file:///C:/foo gives pathname=/C:/foo — strip the leading slash + // so the result is a valid Windows absolute path (C:/foo). + const c1 = decoded.charCodeAt(1) + const isWindowsDrive = + decoded.length >= 4 && + decoded.charCodeAt(0) === 47 /* / */ && + decoded.charCodeAt(2) === 58 /* : */ && + ((c1 >= 65 && c1 <= 90) /* A-Z */ || (c1 >= 97 && c1 <= 122)) /* a-z */ + return isWindowsDrive ? decoded.slice(1) : decoded + } catch { + return undefined + } + } + // Skip actual URLs and non-file schemes (mailto:, tel:, etc.) + if (href.includes("://") || SCHEME_RE.test(href)) return undefined + // Skip pure anchors + if (href.startsWith("#")) return undefined + // Strip fragment and query before treating as file path + const cleaned = href.replace(/[#?].*$/, "") + if (!cleaned) return undefined + // Must look like a file path (has a dot for extension) + if (!cleaned.includes(".")) return undefined + return cleaned +} diff --git a/packages/kilo-ui/src/stories/basic-tool.stories.tsx b/packages/kilo-ui/src/stories/basic-tool.stories.tsx index 5816c932d38..d677af17647 100644 --- a/packages/kilo-ui/src/stories/basic-tool.stories.tsx +++ b/packages/kilo-ui/src/stories/basic-tool.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { BasicTool, GenericTool } from "@opencode-ai/ui/basic-tool" +import { ToolCall, GenericTool } from "@opencode-ai/ui/basic-tool" const meta: Meta = { title: "Components/BasicTool", @@ -12,37 +12,43 @@ type Story = StoryObj export const Default: Story = { render: () => ( - +
{`export const hello = () => "world"`}
-
+
), } export const WithArgs: Story = { render: () => ( - +
{"added 42 packages in 3s"}
-
+ ), } export const DefaultOpen: Story = { render: () => ( - +
Found 12 matches across 5 files.
-
+ ), } export const NoChildren: Story = { - render: () => , + render: () => , } export const Locked: Story = { render: () => ( - +
{"Hello, world!"}
-
+
), } @@ -53,13 +59,13 @@ export const Generic: Story = { export const AllVariants: Story = { render: () => (
- +
content here
-
- + +
All tests passed!
-
- + +
), diff --git a/packages/kilo-ui/src/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index 6278757b755..112e29e236a 100644 --- a/packages/kilo-ui/src/stories/message-part.stories.tsx +++ b/packages/kilo-ui/src/stories/message-part.stories.tsx @@ -1,8 +1,6 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { Message, AssistantMessageDisplay, UserMessageDisplay } from "@opencode-ai/ui/message-part" -// Side-effect import: registers kilo-ui's PART_MAPPING override for reasoning blocks -import "../components/message-part" +import { UserMessageDisplay, AssistantParts } from "../components/message-part" import { DataProvider } from "@opencode-ai/ui/context/data" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" @@ -59,6 +57,8 @@ const userTextPart: TextPart = { text: "Can you review my code and suggest improvements?", } +// --- Context group tools (read/glob/grep/list) → grouped header path --- + const completedToolPart: ToolPart = { id: "part-tool-001", sessionID: SESSION_ID, @@ -76,17 +76,73 @@ const completedToolPart: ToolPart = { }, } -const runningToolPart: ToolPart = { - id: "part-tool-002", +const grepCompleted: ToolPart = { + id: "part-grep-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-grep-001", + tool: "grep", + state: { + status: "completed", + input: { pattern: "createSignal", path: "src/" }, + output: + "src/counter.tsx:1: import { createSignal } from 'solid-js'\nsrc/app.tsx:3: const [count, setCount] = createSignal(0)", + title: "Search", + metadata: {}, + time: { start: now - 7800, end: now - 7400 }, + }, +} + +const globCompleted: ToolPart = { + id: "part-glob-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-glob-001", + tool: "glob", + state: { + status: "completed", + input: { pattern: "src/**/*.tsx", path: "." }, + output: "src/counter.tsx\nsrc/app.tsx\nsrc/index.tsx", + title: "Found 3 files", + metadata: {}, + time: { start: now - 7600, end: now - 7300 }, + }, +} + +// --- Bash tool → ShellRollingResults path --- + +const bashCompleted: ToolPart = { + id: "part-bash-001", sessionID: SESSION_ID, messageID: ASST_MSG_ID, type: "tool", callID: "call-bash-001", tool: "bash", state: { - status: "running", + status: "completed", input: { description: "Run tests", command: "bun test" }, - title: "Running tests...", + output: + "bun test v1.0.0\n\n✓ counter.test.tsx > renders correctly (2ms)\n✓ counter.test.tsx > increments count (1ms)\n\n2 tests passed [34ms]", + title: "Run tests", + metadata: {}, + time: { start: now - 5000, end: now - 4500 }, + }, +} + +// --- Edit tool → normal Part/ToolPartDisplay path (non-bash, non-context-group) --- + +const runningToolPart: ToolPart = { + id: "part-tool-002", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-edit-001", + tool: "edit", + state: { + status: "running", + input: { filePath: "src/counter.tsx" }, metadata: {}, time: { start: now - 3000 }, }, @@ -97,17 +153,18 @@ const errorToolPart: ToolPart = { sessionID: SESSION_ID, messageID: ASST_MSG_ID, type: "tool", - callID: "call-bash-002", - tool: "bash", + callID: "call-edit-002", + tool: "edit", state: { status: "error", - input: { description: "Build project", command: "bun build" }, - error: "Build failed: Module not found 'missing-dep'", - metadata: {}, + input: { filePath: "src/missing-file.tsx" }, + error: "ENOENT: no such file or directory 'src/missing-file.tsx'", time: { start: now - 6000, end: now - 5500 }, }, } +// --- Reasoning part --- + const reasoningPart: ReasoningPart = { id: "part-reasoning-001", sessionID: SESSION_ID, @@ -117,22 +174,44 @@ const reasoningPart: ReasoningPart = { time: { start: now - 9000, end: now - 8500 }, } -const mockData = { - session: [], - session_status: {}, - session_diff: {}, - message: { - [SESSION_ID]: [mockUserMessage, mockAssistantMessage], - }, - part: { - [USER_MSG_ID]: [userTextPart], - [ASST_MSG_ID]: [textPart, completedToolPart], - }, +// --------------------------------------------------------------------------- +// Mock data factory — static constants avoid re-creating arrays on every +// reactive access, which would cause SolidJS to see constant "changes". +// --------------------------------------------------------------------------- + +function createMockData(parts: (TextPart | ToolPart | ReasoningPart)[]) { + return { + session: [], + session_status: {}, + session_diff: {}, + message: { + [SESSION_ID]: [mockUserMessage, mockAssistantMessage], + }, + part: { + [USER_MSG_ID]: [userTextPart], + [ASST_MSG_ID]: parts, + }, + } } -function AllProviders(props: { children: any }) { +type MockData = ReturnType + +// AssistantMessage + text + completed read (context group grouped header) +const mockData = createMockData([completedToolPart, textPart]) +// Only the running edit tool — matches old WithRunningTool single-part intent +const mockDataRunning = createMockData([runningToolPart]) +// Only the error edit tool — matches old WithErrorTool single-part intent +const mockDataError = createMockData([errorToolPart]) +// Reasoning + text — matches old WithReasoning single-reasoning intent +const mockDataReasoning = createMockData([reasoningPart, textPart]) +// Completed bash tool — exercises ShellRollingResults path +const mockDataBash = createMockData([bashCompleted]) +// Three context-group tools — exercises ContextToolGroupHeader collapse path +const mockDataContextGroup = createMockData([completedToolPart, grepCompleted, globCompleted, textPart]) + +function AllProviders(props: { children: any; data?: MockData }) { return ( - + @@ -148,6 +227,10 @@ function AllProviders(props: { children: any }) { ) } +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + const meta: Meta = { title: "Components/MessagePart", parameters: { layout: "padded" }, @@ -156,6 +239,8 @@ const meta: Meta = { export default meta type Story = StoryObj +// --- User message bubble --- + export const UserMessageStory: Story = { name: "UserMessage", render: () => ( @@ -165,76 +250,106 @@ export const UserMessageStory: Story = { ), } +// --- Assistant: text + one completed read tool (grouped header) --- + export const AssistantMessageStory: Story = { name: "AssistantMessage", render: () => ( - + ), } +// --- Edit tool in running/pending state (Part → ToolPartDisplay path) --- + export const WithRunningTool: Story = { render: () => ( - - + + ), } +// --- Edit tool in error state → renders red error card --- + export const WithErrorTool: Story = { render: () => ( - - + + ), } +// --- User + assistant stacked (tool first, then text — matches original order) --- + export const FullConversationTurn: Story = { render: () => (
- +
), } +// --- Reasoning block collapsed (default state) --- + export const WithReasoningCollapsed: Story = { name: "WithReasoning (collapsed)", render: () => ( - - + + ), } +// --- Reasoning block expanded via play interaction --- + export const WithReasoningExpanded: Story = { name: "WithReasoning (expanded)", - render: () => { - // Use a wrapper to render with the collapsible open by default - const expandedReasoningPart = { ...reasoningPart, id: "part-reasoning-expanded" } - return ( - - - - ) - }, + render: () => ( + + + + ), play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - // Click the collapsible trigger to expand it const trigger = canvasElement.querySelector("[data-slot='reasoning-header']")?.closest("button") if (trigger) trigger.click() }, } -export const MessageSwitch: Story = { +// --- Bash tool (completed, collapsed) — exercises the ShellRollingResults path --- + +export const WithBashTool: Story = { render: () => ( - -
- - -
+ + + + ), +} + +// --- Bash tool (completed, expanded) — play clicks the header to open the output panel --- + +export const WithBashToolExpanded: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const header = canvasElement.querySelector("[data-slot='shell-rolling-header-clip']") as HTMLElement | null + if (header) header.click() + }, +} + +// --- Three context-group tools + text — exercises ContextToolGroupHeader collapse --- + +export const WithContextGroup: Story = { + render: () => ( + + ), } diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts b/packages/kilo-ui/tests/visual-regression.spec.ts index 24662af93fe..385f1b61809 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts +++ b/packages/kilo-ui/tests/visual-regression.spec.ts @@ -32,6 +32,19 @@ async function fetchStories(): Promise { return Object.values(map).filter((s) => s.id && !s.id.endsWith("--docs")) } +// Animation protection is layered: +// 1. playwright.config.ts `reducedMotion: "reduce"` — emulates the OS +// prefers-reduced-motion media feature before the page loads, so the +// module-level `prefersReducedMotion()` signal (use-reduced-motion.ts) +// initialises to `true`. This disables all JS spring animations driven +// by GrowBox, useSpring, useToolFade, useRowWipe, ShellRollingResults +// and ContextToolRollingResults. +// 2. CSS injection below — zeroes out any remaining CSS animation/transition +// durations (Kobalte collapsible, Tailwind transitions, etc.). +// 3. waitForSettled() — flushes two requestAnimationFrame ticks so that +// SolidJS reactive effects triggered by onMount (e.g. the `mounted()` +// signal one-tick delay in ShellRollingResults) have run and inline +// styles have been updated before the screenshot is taken. async function disableAnimations(page: Page) { await page.addStyleTag({ content: ` @@ -40,11 +53,24 @@ async function disableAnimations(page: Page) { animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; + scroll-behavior: auto !important; } `, }) } +// Flush two animation frames so all SolidJS reactive effects that were +// scheduled during mount (e.g. mounted() signal flips) have been processed +// and written to the DOM before we take a screenshot. +async function waitForSettled(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }), + ) +} + // Wait for every inside the story root to finish loading so screenshots // never capture an intermediate state with missing / half-loaded images. async function waitForImages(page: Page) { @@ -63,6 +89,38 @@ async function waitForImages(page: Page) { }) } +// Wait for all @font-face declarations to resolve so screenshots are never +// taken while fallback fonts (Arial / Courier New) are still active. +async function waitForFonts(page: Page) { + await page.evaluate(() => document.fonts.ready) +} + +// Wait for the DOM inside #storybook-root to be idle for `idle` ms (capped at +// `timeout` ms). This ensures Storybook play() functions and any deferred +// renders (setTimeout, deferredHighlight, etc.) have settled before the screenshot. +async function waitForStable(page: Page, timeout = 2000, idle = 200) { + await page.evaluate( + ({ t, i }: { t: number; i: number }) => + new Promise((resolve) => { + let timer: ReturnType + const root = document.getElementById("storybook-root") + const done = () => { + observer.disconnect() + resolve() + } + const reset = () => { + clearTimeout(timer) + timer = setTimeout(done, i) + } + const observer = new MutationObserver(reset) + if (root) observer.observe(root, { childList: true, subtree: true, attributes: true, characterData: true }) + timer = setTimeout(done, i) + setTimeout(done, t) + }), + { t: timeout, i: idle }, + ) +} + // Stories to skip from visual regression: // - Font/Favicon: inject into , no visible content in #storybook-root // - Typewriter: uses JS setTimeout + Math.random(), inherently non-deterministic @@ -91,6 +149,15 @@ for (const story of stories) { // Wait for Kobalte/SolidJS to finish hydrating interactive components await page.waitForSelector("#storybook-root *", { state: "attached" }) await waitForImages(page) + // Wait for @font-face declarations to resolve (font-display: swap can + // defer swapping to the custom font, causing screenshot diffs). + await waitForFonts(page) + // Flush pending rAF ticks so SolidJS onMount effects (e.g. mounted() + // signal) have updated inline styles before the screenshot is taken. + await waitForSettled(page) + // Wait for the DOM to stop mutating — ensures Storybook play() functions + // and deferred renders (setTimeout, deferredHighlight) have settled. + await waitForStable(page) // Screenshot just the story content, not the full 1280x720 canvas. // Use [component, variant] path so snapshots are grouped per component dir. diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/all-variants-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/all-variants-chromium-linux.png index 20154d22df3..b1c1d33f12f 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/all-variants-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/all-variants-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfd2a0c4eb4e848a61d1cc309b637980d71e09325fb330f4329df05e754464d0 -size 9497 +oid sha256:892766e5ded073050d1df3c228ca0e65f67ac77e1bc709bd2be01c4753c4e18c +size 10191 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-chromium-linux.png index ee216a48e27..2ed24813467 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ff2fc5b2f48973c4f217292fca770cb8fafafaf084d512d81486243656345b3 -size 2469 +oid sha256:c1d6cbd6edb9fa8d5c0d95691f8286c2cee9cd7c16aed4f7735c3191db82fa44 +size 2514 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-open-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-open-chromium-linux.png index 58e123c1840..9e769707baf 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-open-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6213f881a8eaa9a6fde7e4f46fbced82a308afe426433330426469975e9d670e -size 4292 +oid sha256:4a6300e8bb2b5dff0ea80797c0fb0b47cc585c6a0eccd6374677e753e19b5128 +size 4248 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/generic-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/generic-chromium-linux.png index 9175106d1be..edf8701bfef 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/generic-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/generic-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2691a4b945908a3b352915cde3f306f4eae7a048e1c836260cae82bc1beb77ce -size 2346 +oid sha256:093cdb1c2669500746ceb326b36c7db52f8013b9682a33af37dc9021e95c3a3a +size 2388 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/locked-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/locked-chromium-linux.png index 8f118ea58aa..28353747f43 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/locked-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/locked-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afb54400b855748eac7722a9cef3877fa0b70fd56f466d396a93559d7634767c -size 3637 +oid sha256:64ef7ae336eab120d84531a41efdce837d2ea9a9eea100aa9160c246d0f546ef +size 3612 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/no-children-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/no-children-chromium-linux.png index d4efee41053..4870bbfdb15 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/no-children-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/no-children-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:143ce0f9a640b0558d381485972f5818feacd84d2a626187dfe644f86d46244e -size 1772 +oid sha256:4ed9a4460f71b4220eb4b61f93b4982780611da1ad26ae2a0c9c97149469cc89 +size 1814 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/with-args-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/with-args-chromium-linux.png index 370a29bbb07..c7a6bd4a931 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/with-args-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/with-args-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53f1ef3da39ca34fe50fe48f36a607c83e5b751a471812e1e3eb41d8aaeac5d1 -size 3433 +oid sha256:03eda52d0f8e482275cccd33183d743c285f8af4636f0e6c524bebf61466231c +size 3484 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/all-variants-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/all-variants-chromium-linux.png index e9b02457fe8..9e74c42f7a1 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/all-variants-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/all-variants-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:050b264c0a664953e665d855066cf2282f41ed8e2aeb449d833929ca09ff84c5 -size 5045 +oid sha256:ce263310ed424fae17883e153cf6ca85b9755f81057de7b9dd56b956401e0e79 +size 5079 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/collapsed-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/collapsed-chromium-linux.png index 212b6e437df..92874cd204d 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/collapsed-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc575023f5ede7ba8d8dc0fd88146826d40efa9953e8aff6bc5222f1eed14864 -size 1572 +oid sha256:bc01e4072954924fdc17399cfb9aa8a0592b6fdc9da9f7d02648b3840f6a84b3 +size 1589 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/ghost-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/ghost-chromium-linux.png index 6d9ab4af707..52291375c28 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/ghost-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/ghost-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93a98b91f13adc8dd43b9116790cec95bba57cc71a99eda61bb911c0a7084a29 -size 4994 +oid sha256:a1e22894c93a21abc3d0da24c93ef0c6954b634df5128c91176385a034e5a6e4 +size 5011 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/normal-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/normal-chromium-linux.png index a340163e990..4d3ab0066c9 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/normal-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/normal-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4514352c4d34fce3cb09f2f16fcd7fc817a488bf2424aa09297532207ea1fad -size 5199 +oid sha256:c9018e8bb702208b571ef0397bacc0c33cd2ce1c1fde29b24c84fc0f9f8c59c1 +size 5216 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/collapsed-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/collapsed-chromium-linux.png index dc5d912aa81..b87b33b4a65 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/collapsed-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76e79e29216858903b848fb108d0358af251641748bf9ad55f87ecfb9f5f6429 -size 3103 +oid sha256:6bb3943d74d7535efd4bb0156d5924955e86097825b8cee5ce63a42e7104e2ea +size 3136 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/expanded-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/expanded-chromium-linux.png index be157fdb118..f775a84de97 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/expanded-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79a46aae44755c42c68fcbd25c866547921b1bd494c62ef5796cc9826b288f59 -size 11697 +oid sha256:09d1f66ca9e9dea9baefaa17790c97101391891e8d4ea747d985f97b72378d3a +size 11730 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/assistant-message-story-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/assistant-message-story-chromium-linux.png index 96459b9b664..72201db5bba 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/assistant-message-story-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/assistant-message-story-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f36283f93103024b8af2a93e6233cfff6dc8fbc2a5fe273de0f9e709e8f8bc13 -size 15965 +oid sha256:5767e400815901215f48c6617781b98a9bb69791e5dc4d305ba1fe72573cf27b +size 14903 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/full-conversation-turn-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/full-conversation-turn-chromium-linux.png index 39b2a59a88b..8be40cff317 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/full-conversation-turn-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/full-conversation-turn-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fe46a0d03b43df057a7aea430ee08f21e4406680da295568a242c94bfd57f32 -size 20113 +oid sha256:2d5ccab196fbd2c25a9e4fd42f8f4c9600dce8a5a326b568144f7f8a37daa690 +size 18956 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/message-switch-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/message-switch-chromium-linux.png deleted file mode 100644 index 3e94c1e46c7..00000000000 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/message-switch-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:73f46e8054adeecf2baf85a3c688b2ecfb0b56e697061c9f2f8114ab0882e392 -size 20113 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/user-message-story-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/user-message-story-chromium-linux.png index 29d9725da84..e4f27c81f3e 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/user-message-story-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/user-message-story-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b63ac69598ce0bbcb2ef674f0fb07ebc746f36f188d09a1a392661b9436d3916 -size 4138 +oid sha256:d67fc85ffba0fcc20ddf3c88979a4f748691c62283d45e3a2e95ad9e31a0618b +size 3818 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-chromium-linux.png new file mode 100644 index 00000000000..49a904c94c3 --- /dev/null +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a914f2b8ba47d4b13f0d54d928008edc3e4263d3fa1e4bd6d9c5b5a3641a08b +size 2030 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-expanded-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-expanded-chromium-linux.png new file mode 100644 index 00000000000..e8248cfecf4 --- /dev/null +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-expanded-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d936937d43952f0b0f49e79538de8a820468b5a64d2b55098a0f26009623c81 +size 11792 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-context-group-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-context-group-chromium-linux.png new file mode 100644 index 00000000000..e978a456c74 --- /dev/null +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-context-group-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:655f094799ce2b88258fa72a6b464a2bd60d1319553872fc8b2d763e84650ebc +size 15487 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-error-tool-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-error-tool-chromium-linux.png index 10a1584458d..a2e15f6406c 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-error-tool-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-error-tool-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:61695c2c9760589c8f190dba0442de993cc8a362c360c1fa55cc23709979eabb -size 3983 +oid sha256:60821e2616224170657983d1df12ebc5b282efbb2973df8dd8156cdab2e1d20c +size 3729 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-collapsed-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-collapsed-chromium-linux.png index 36ab5c857cb..d875dc17619 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-collapsed-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67cb95242834a2ac3633b70a68a8ff9a9f343938092f06926a44639ba5c01121 -size 15968 +oid sha256:f6cb80426de3b5412ed5c1cc48e79240dbfee8652fe10f87e35ef28394d68bf5 +size 14736 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-expanded-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-expanded-chromium-linux.png index a72714105d9..1bcd7f3094d 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-expanded-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d93bf6465c33895aa54bc2479315ea2fba38d06488f3d7cdf868b60caaefd9e -size 32623 +oid sha256:684cdf624098db3f38ecb0c5fa3f6120bea1e3cfaacded0bc38b370fca46d01b +size 28039 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-running-tool-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-running-tool-chromium-linux.png index 8c8412fcc44..6a1bf8eae02 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-running-tool-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-running-tool-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96ead064ea9e170ae1323929aeba0734da99f5b1126f869ee6beaa0cd3f1353e -size 2893 +oid sha256:86341416eed91ef83dccb3619c258f68510e835bfb1c0bef47d26e6eecd16766 +size 2479 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png index 59a093a8e7e..3d7da3a8c7b 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cba87b80407953fa44ed0acf4b5069f4bd0a06ea864e79db6f1bbc43c38d8127 -size 31323 +oid sha256:c4d189a85d1955e25f5624d87db22e49f58206223d88a5b7ef64536f6379c0b9 +size 26967 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png index 59a093a8e7e..3d7da3a8c7b 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cba87b80407953fa44ed0acf4b5069f4bd0a06ea864e79db6f1bbc43c38d8127 -size 31323 +oid sha256:c4d189a85d1955e25f5624d87db22e49f58206223d88a5b7ef64536f6379c0b9 +size 26967 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/working-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/working-chromium-linux.png index 8d8086c7da2..d1167bbf651 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/working-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/working-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a3c885d4186d6d372e417768e2cb0919da9943eca92b58af46f40d45ea7f3c4 -size 6223 +oid sha256:8001c853e8f67303e6738c26e23a03d9da11d0e05d32011ad1163394c2f2fb0b +size 5783 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/force-open-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/force-open-chromium-linux.png index 3fb9feec749..877f735e988 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/force-open-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/force-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d59c5c23953d68593619b339170ae2368d53cbcc598334bc9e9ab6856ae4fc28 -size 2122 +oid sha256:6f513acb423e33244a6cc95be1d511fd8d218c817f8faec211a3df31056e9fea +size 2017 diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index 43d283ac7a5..cb9b162560c 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -30,6 +30,7 @@ const targets = [ { target: "darwin-x64", cliDir: "@kilocode/cli-darwin-x64", binary: "kilo" }, { target: "darwin-arm64", cliDir: "@kilocode/cli-darwin-arm64", binary: "kilo" }, { target: "win32-x64", cliDir: "@kilocode/cli-windows-x64", binary: "kilo.exe" }, + { target: "win32-arm64", cliDir: "@kilocode/cli-windows-arm64", binary: "kilo.exe" }, ] const binDir = join(import.meta.dir, "..", "bin") diff --git a/packages/kilo-vscode/script/publish.ts b/packages/kilo-vscode/script/publish.ts index f4d20b1bfc6..87ce086f029 100755 --- a/packages/kilo-vscode/script/publish.ts +++ b/packages/kilo-vscode/script/publish.ts @@ -13,7 +13,16 @@ if (!existsSync(outDir)) { throw new Error(`VSIX directory not found: ${outDir}`) } -const targets = ["linux-x64", "linux-arm64", "alpine-x64", "alpine-arm64", "darwin-x64", "darwin-arm64", "win32-x64"] +const targets = [ + "linux-x64", + "linux-arm64", + "alpine-x64", + "alpine-arm64", + "darwin-x64", + "darwin-arm64", + "win32-x64", + "win32-arm64", +] const vsixFiles: string[] = [] for (const target of targets) { diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index e58a4182e89..ddfe2be0fae 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -59,6 +59,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedAgentsMessage: unknown = null /** Cached skillsLoaded payload so requestSkills can be served before client is ready */ private cachedSkillsMessage: unknown = null + /** Cached commandsLoaded payload so requestCommands can be served before client is ready */ + private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null /** Cached notificationsLoaded payload */ @@ -423,6 +425,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) break } + case "sendCommand": { + const files = z + .array( + z.object({ + mime: z.string(), + url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")), + }), + ) + .optional() + .catch(undefined) + .parse(message.files) + await this.handleSendCommand( + message.command, + message.arguments, + typeof message.messageID === "string" ? message.messageID : undefined, + message.sessionID, + message.providerID, + message.modelID, + message.agent, + message.variant, + files, + ) + break + } case "abort": await this.handleAbort(message.sessionID) break @@ -519,6 +545,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestSkills": this.fetchAndSendSkills().catch((e) => console.error("[Kilo New] fetchAndSendSkills failed:", e)) break + case "requestCommands": + this.fetchAndSendCommands().catch((e) => console.error("[Kilo New] fetchAndSendCommands failed:", e)) + break case "removeSkill": this.removeSkillViaCli(message.location).catch((e: unknown) => console.error("[Kilo New] removeSkill failed:", e), @@ -652,6 +681,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.agent, message.variant, files, + typeof message.command === "string" ? message.command : undefined, + typeof message.commandArgs === "string" ? message.commandArgs : undefined, ) break } @@ -735,9 +766,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const scope = message.mpInstallOptions?.target ?? "project" const result = await this.getMarketplace().install(message.mpItem, message.mpInstallOptions, workspace) if (result.success) { - await this.disposeCliInstance(scope) - this.cachedAgentsMessage = null - await this.fetchAndSendAgents() + await this.invalidateAfterMarketplaceChange(scope) } this.postMessage({ type: "marketplaceInstallResult", @@ -752,9 +781,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const scope = message.mpInstallOptions?.target ?? "project" const result = await this.getMarketplace().remove(message.mpItem, scope, workspace) if (result.success) { - await this.disposeCliInstance(scope) - this.cachedAgentsMessage = null - await this.fetchAndSendAgents() + await this.invalidateAfterMarketplaceChange(scope) } this.postMessage({ type: "marketplaceRemoveResult", @@ -872,6 +899,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendProviders(), this.fetchAndSendAgents(), this.fetchAndSendSkills(), + this.fetchAndSendCommands(), this.fetchAndSendConfig(), this.fetchAndSendNotifications(), ]) @@ -1277,6 +1305,34 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async fetchAndSendCommands(): Promise { + if (!this.client) { + if (this.cachedCommandsMessage) { + this.postMessage(this.cachedCommandsMessage) + } + return + } + + try { + const dir = this.getWorkspaceDirectory() + const { data: commands } = await this.client.command.list({ directory: dir }, { throwOnError: true }) + + const message = { + type: "commandsLoaded", + commands: commands.map((c) => ({ + name: c.name, + description: c.description, + source: c.source, + hints: c.hints, + })), + } + this.cachedCommandsMessage = message + this.postMessage(message) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch commands:", error) + } + } + private async fetchCliSkills(): Promise | undefined> { if (!this.client) return undefined try { @@ -1302,16 +1358,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (result.error) { console.error("[Kilo New] removeSkill returned error:", result.error) this.cachedSkillsMessage = null - await this.fetchAndSendSkills() + this.cachedCommandsMessage = null + await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()]) return false } } catch (error) { console.error("[Kilo New] Failed to remove skill:", error) this.cachedSkillsMessage = null - await this.fetchAndSendSkills() + this.cachedCommandsMessage = null + await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()]) return false } this.cachedSkillsMessage = null + this.cachedCommandsMessage = null + await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()]) return true } @@ -1395,6 +1455,43 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + /** + * Invalidate CLI caches and refresh the webview after a marketplace install/remove. + * + * For global scope: uses global.config.update with the freshly-written config file + * contents rather than global.dispose. This goes through Config.updateGlobal() which + * calls Config.global.reset() to invalidate the lazy-cached global config, ensuring + * the newly installed/removed MCP entry is visible on the next config.get call. + * (global.dispose alone is not sufficient on older CLI versions that lack the + * Config.global.reset() call in the dispose handler.) + * + * For project scope: instance.dispose is sufficient because the per-instance + * Config.state is cleared and re-reads all files (including global) on next access. + */ + private async invalidateAfterMarketplaceChange(scope: "project" | "global"): Promise { + if (!this.client) return + if (scope === "global") { + // Use global.config.update with an empty config to trigger Config.updateGlobal() + // which calls Config.global.reset(). This invalidates the lazy-cached global + // config in the CLI process so it re-reads kilo.json from disk. + // An empty object merge is a no-op for the file content but resets the cache. + // (global.dispose alone is insufficient on older CLI versions that lack + // the Config.global.reset() call in the dispose handler.) + await this.client.global.config.update({ config: {} }).catch((e: unknown) => { + console.warn("[Kilo New] global.config.update after marketplace change failed:", e) + }) + } + // Always dispose the per-project instance so it rebuilds state from + // the (possibly updated) global + project config on the next request. + const dir = this.getWorkspaceDirectory() + await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => { + console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e) + }) + this.cachedAgentsMessage = null + this.cachedConfigMessage = null + await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()]) + } + /** * Fetch backend config and send to webview. */ @@ -1562,6 +1659,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper agent?: string, variant?: string, files?: Array<{ mime: string; url: string }>, + command?: string, + commandArgs?: string, ): Promise { if (!this.client) { this.postMessage({ @@ -1611,37 +1710,52 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper session: this.sessionToWebview(session), }) - // Step 2: Send the user's message on the new local session - const parts: Array = [] - - if (files) { - for (const f of files) { - parts.push({ type: "file", mime: f.mime, url: f.url }) - } - } - - parts.push({ type: "text", text }) - + // Step 2: Send the user's message/command on the new local session try { - const editorContext = await this.gatherEditorContext() - if (messageID) { this.connectionService.recordMessageSessionId(messageID, session.id) } - await this.client.session.promptAsync( - { - sessionID: session.id, - directory: workspaceDir, - messageID, - parts, - model: providerID && modelID ? { providerID, modelID } : undefined, - agent, - variant, - editorContext, - }, - { throwOnError: true }, - ) + if (command) { + const fileParts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) + await this.client.session.command( + { + sessionID: session.id, + directory: workspaceDir, + command, + arguments: commandArgs ?? "", + messageID, + model: providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent, + variant, + parts: fileParts, + }, + { throwOnError: true }, + ) + } else { + const parts: Array = [] + if (files) { + for (const f of files) { + parts.push({ type: "file", mime: f.mime, url: f.url }) + } + } + parts.push({ type: "text", text }) + + const editorContext = await this.gatherEditorContext() + await this.client.session.promptAsync( + { + sessionID: session.id, + directory: workspaceDir, + messageID, + parts, + model: providerID && modelID ? { providerID, modelID } : undefined, + agent, + variant, + editorContext, + }, + { throwOnError: true }, + ) + } } catch (err) { console.error("[Kilo New] Failed to send message after cloud import:", err) this.postMessage({ @@ -1743,6 +1857,32 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Uses promptAsync (fire-and-forget) — the server queues concurrent prompts * internally, so the client doesn't need to block on busy sessions. */ + /** + * Ensure a session exists, creating one if needed. Returns the resolved + * session ID and workspace directory, or undefined when the client is + * disconnected. + */ + private async resolveSession(sessionID?: string): Promise<{ sid: string; dir: string } | undefined> { + if (!this.client) return undefined + + const dir = this.getWorkspaceDirectory(sessionID || this.currentSession?.id) + + if (!sessionID && !this.currentSession) { + const { data: session } = await this.client.session.create({ directory: dir }, { throwOnError: true }) + this.currentSession = session + this.trackedSessionIds.add(session.id) + this.postMessage({ + type: "sessionCreated", + session: this.sessionToWebview(session), + }) + } + + const sid = sessionID || this.currentSession?.id + if (!sid) throw new Error("No session available") + this.trackedSessionIds.add(sid) + return { sid, dir } + } + private async handleSendMessage( text: string, messageID?: string, @@ -1765,53 +1905,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } - let targetSessionID = sessionID + let resolved: { sid: string; dir: string } | undefined try { - const workspaceDir = this.getWorkspaceDirectory(sessionID || this.currentSession?.id) + resolved = await this.resolveSession(sessionID) - // Create session if needed - if (!sessionID && !this.currentSession) { - const { data: newSession } = await this.client.session.create( - { directory: workspaceDir }, - { throwOnError: true }, - ) - this.currentSession = newSession - this.trackedSessionIds.add(this.currentSession.id) - // Notify webview of the new session - this.postMessage({ - type: "sessionCreated", - session: this.sessionToWebview(this.currentSession), - }) - } - - targetSessionID = sessionID || this.currentSession?.id - if (!targetSessionID) { - throw new Error("No session available") - } - this.trackedSessionIds.add(targetSessionID) - - // Build parts array with file context and user text const parts: Array = [] - - // Add any explicitly attached files from the webview if (files) { for (const f of files) { parts.push({ type: "file", mime: f.mime, url: f.url }) } } - parts.push({ type: "text", text }) const editorContext = await this.gatherEditorContext() if (messageID) { - this.connectionService.recordMessageSessionId(messageID, targetSessionID) + this.connectionService.recordMessageSessionId(messageID, resolved!.sid) } await this.client.session.promptAsync( { - sessionID: targetSessionID, - directory: workspaceDir, + sessionID: resolved!.sid, + directory: resolved!.dir, messageID, parts, model: providerID && modelID ? { providerID, modelID } : undefined, @@ -1827,7 +1942,67 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper type: "sendMessageFailed", error: getErrorMessage(error) || "Failed to send message", text, - sessionID: targetSessionID, + sessionID: resolved?.sid ?? sessionID, + messageID, + files, + }) + } + } + + private async handleSendCommand( + command: string, + args: string, + messageID?: string, + sessionID?: string, + providerID?: string, + modelID?: string, + agent?: string, + variant?: string, + files?: Array<{ mime: string; url: string }>, + ): Promise { + if (!this.client) { + this.postMessage({ + type: "sendMessageFailed", + error: "Not connected to CLI backend", + text: `/${command} ${args}`.trim(), + sessionID, + messageID, + files, + }) + return + } + + let resolved: { sid: string; dir: string } | undefined + try { + resolved = await this.resolveSession(sessionID) + + if (messageID) { + this.connectionService.recordMessageSessionId(messageID, resolved!.sid) + } + + const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) + + await this.client.session.command( + { + sessionID: resolved!.sid, + directory: resolved!.dir, + command, + arguments: args, + messageID, + model: providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent, + variant, + parts, + }, + { throwOnError: true }, + ) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to send command:", error) + this.postMessage({ + type: "sendMessageFailed", + error: getErrorMessage(error) || "Failed to send command", + text: `/${command} ${args}`.trim(), + sessionID: resolved?.sid ?? sessionID, messageID, files, }) @@ -2351,6 +2526,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendProviders(), this.fetchAndSendAgents(), this.fetchAndSendSkills(), + this.fetchAndSendCommands(), this.fetchAndSendConfig(), this.fetchAndSendNotifications(), ]) diff --git a/packages/kilo-vscode/src/services/marketplace/installer.ts b/packages/kilo-vscode/src/services/marketplace/installer.ts index b1d2bcb9b58..bac22e79f4b 100644 --- a/packages/kilo-vscode/src/services/marketplace/installer.ts +++ b/packages/kilo-vscode/src/services/marketplace/installer.ts @@ -80,7 +80,8 @@ export class MarketplaceInstaller { private buildMcpEntry(content: string, params?: Record): Record { const filtered = Object.fromEntries(Object.entries(params ?? {}).filter(([k]) => k !== "__method")) const replaced = Object.keys(filtered).length > 0 ? substituteParams(content, filtered) : content - return JSON.parse(replaced) + const raw = JSON.parse(replaced) as Record + return normalizeMcpEntry(raw) } // ── Mode ──────────────────────────────────────────────────────────── @@ -273,6 +274,48 @@ export class MarketplaceInstaller { // ── Helpers ───────────────────────────────────────────────────────── +/** + * Normalize a marketplace MCP entry from the old Kilocode format to the CLI's expected format. + * + * Old format (from marketplace API): + * { "command": "npx", "args": [...], "env": {...} } + * { "type": "sse"|"streamable-http", "url": "...", "headers": {...} } + * + * New format (CLI Config.Mcp schema): + * { "type": "local", "command": ["npx", ...], "environment": {...} } + * { "type": "remote", "url": "...", "headers": {...} } + */ +function normalizeMcpEntry(raw: Record): Record { + // Already in new format + if (raw.type === "local" || raw.type === "remote") return raw + + // Remote MCP (sse / streamable-http) → type: "remote" + if (typeof raw.url === "string") { + const { type: _type, url, headers, ...rest } = raw + const entry: Record = { type: "remote", url } + if (headers && typeof headers === "object") entry.headers = headers + // Carry through any other recognized fields (enabled, timeout, oauth) + for (const key of ["enabled", "timeout", "oauth"] as const) { + if (key in rest) entry[key] = rest[key] + } + return entry + } + + // Local MCP (command string + args array) → type: "local", command array + if (typeof raw.command === "string") { + const args = (raw.args as string[] | undefined) ?? [] + const env = raw.env + const entry: Record = { type: "local", command: [raw.command, ...args] } + if (env && typeof env === "object" && Object.keys(env as object).length > 0) entry.environment = env + for (const key of ["enabled", "timeout"] as const) { + if (key in raw) entry[key] = raw[key] + } + return entry + } + + return raw +} + function isSafeId(id: string): boolean { if (!id || id.includes("..") || id.includes("/") || id.includes("\\")) return false return /^[\w\-@.]+$/.test(id) diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png index 687a7464416..8cd7c12d6af 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8be01b7f273c041e8adaccfadb368dbe8dba5d24271eaf139db372226c5f2e70 -size 11645 +oid sha256:a5124657bf53dd4ad663752134ab7fac8acb5d65c99988e9c8042484e28f0a2b +size 11566 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-approved-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-approved-chromium-linux.png index e8dcced3ee9..7e199e040fe 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-approved-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-approved-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e28ab75b3a37ad38a5dcc0ac9c70c9037d0e93e2ed6863474bf10e6f7c27fb2 -size 11838 +oid sha256:9879579db3b21cd617c36ca6673fa00c4a7ca8f8ab61cea78f3038102659a0e0 +size 11708 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-denied-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-denied-chromium-linux.png index cf8e5907d55..c446e030b03 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-denied-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-denied-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8bb6298af81fb9af542d61eef9098c5351eee017f42d004ba570e84b1e565fc9 -size 11888 +oid sha256:bf1fc64b4979cc1dcb5554acd75e6dad8b39163fdae393e657988eb70d11a543 +size 11719 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png index e733e00537a..b92218235c8 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:423de68e1e0cd788d3190d79c4bdf1649a61359dd0b1a687a736d72e3639f1a1 -size 12080 +oid sha256:6405af72ead31ff6601b85dd65dc7f51b52b5cfb55a1ed933a80c1a3e0590867 +size 11859 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png index c5594a58d8f..9a7b7a9a706 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a8431c9b229b9a62f48349d1b438ebfe94fcf57e6e1cd7aa5c0f0515162e2a4 -size 18442 +oid sha256:a01cf93f325fd8563c76acb1bad751dd7d3f6f14807cda68d950d260ce8b9f8d +size 15374 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png index 6b3843eeaff..41c5709dad3 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e44712ac8f91b9e2f59a3462ff891363d928b35720851f8fdb384350510491ba -size 16016 +oid sha256:8254c7188b24f6fa8c127c1c11568932588bb329db9b9081ec620b64b6d678a7 +size 14027 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png index ecc1ca5297b..c007e6d7330 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c879061b89de20c5469cb67789d70b7cecf7161ff179755e2859f05aac8268f6 -size 16453 +oid sha256:af33f5d026513422db6efc37848eec76d9da511432e5933606ae0be73da684f8 +size 16675 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png index 4d910055123..0f2d7024415 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce015aaf4a61b52e4607f020ba3c676fa094722cd606b978e91a92c7a9e06d9a -size 11886 +oid sha256:ce11ef1bb34bf1e7b15ae317d2582c278b6a96e650f499c50e86be2d2c2b638d +size 12798 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-rule-approved-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-rule-approved-chromium-linux.png index 16017825b1b..275428936ed 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-rule-approved-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-rule-approved-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b83f3543d18956f5b7828534d504b3bc68c782fcf047560bdece89401cde2f9 -size 12018 +oid sha256:b044d3f5f43f66e6949c200cdbbc38613cae8cf5fd636519c64b06f36d6a537c +size 12939 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png index ac96fdbd880..0e8b7e6f4fa 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b787bda694a4237df0eef73c43d3ba8fb3a20e299f2312b8217b008182275254 -size 17150 +oid sha256:75295a154da3a7ecabf16a022fc3ec7d50eedb78fe3910ddad02f031ce26ee10 +size 15412 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-mixed-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-mixed-chromium-linux.png index cb40ce0dc1b..10bba859de8 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-mixed-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-mixed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:978700609a9d9188015b4ae1917c66c25a8391707938411dda91bb72deac8908 -size 18044 +oid sha256:f8de39c5149e26838a302a203ad7790695f4e20b5e12e1d2175e750ed3c9d855 +size 15989 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/subagent-expanded-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/subagent-expanded-chromium-linux.png index 98ebf9eb79f..377a30a3139 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/subagent-expanded-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/subagent-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7756891ffe6a147c7c005801bfe8b4e0945d8a2ac490122816cf39107223f46 -size 13027 +oid sha256:7ff637be3e246adabee67497580d0f541c5d93d9a7c9054e8ca4da3d3edd1106 +size 12987 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png index 6185e95c5af..4f727818c1f 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4319c82183660bdd6b20a2f4386fe617f13b618d7a6ecb5251e10a1b5ed5800b -size 12001 +oid sha256:615179ff1e475a554b696acf519315c9449d5b63a8454318ffa11f29c8171ede +size 12357 diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/write-expanded-pending-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/write-expanded-pending-chromium-linux.png index ee180458957..e4765096531 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/write-expanded-pending-chromium-linux.png +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/write-expanded-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:490eb92b63ae3663318aafbc934d30c7c3750c4e14a2a2d6bc97fd4cbd5170a1 -size 14842 +oid sha256:61141a6dee3748440f12a351a82d5cd936bc427038643cf674cda0a242834ba4 +size 12877 diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 86714fe371b..f4d28fb31e9 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -50,7 +50,8 @@ describe("ToolRegistry tool name contract (runtime)", () => { const names = JSON.stringify(TOOL_NAMES_WE_DEPEND_ON) const result = check(` const hist = { state: null, length: 1, replaceState(s) { hist.state = s }, pushState(s) { hist.state = s }, go() {} } - globalThis.window = globalThis.window || { history: hist, location: { pathname: "/", search: "", hash: "", href: "/", origin: "" }, scrollTo() {}, addEventListener() {}, removeEventListener() {}, confirm() { return false } } + const mql = { matches: false, media: "", onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true } } + globalThis.window = globalThis.window || { history: hist, location: { pathname: "/", search: "", hash: "", href: "/", origin: "" }, scrollTo() {}, addEventListener() {}, removeEventListener() {}, confirm() { return false }, matchMedia() { return mql } } const { ToolRegistry } = await import("./src/components/message-part.tsx") const names = ${names} const missing = names.filter(n => typeof ToolRegistry.render(n) !== "function") @@ -71,7 +72,8 @@ describe("getToolInfo() export contract (runtime)", () => { // outside a SolidJS rendering context. We verify it exists as a function. const result = check(` const hist = { state: null, length: 1, replaceState(s) { hist.state = s }, pushState(s) { hist.state = s }, go() {} } - globalThis.window = globalThis.window || { history: hist, location: { pathname: "/", search: "", hash: "", href: "/", origin: "" }, scrollTo() {}, addEventListener() {}, removeEventListener() {}, confirm() { return false } } + const mql = { matches: false, media: "", onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true } } + globalThis.window = globalThis.window || { history: hist, location: { pathname: "/", search: "", hash: "", href: "/", origin: "" }, scrollTo() {}, addEventListener() {}, removeEventListener() {}, confirm() { return false }, matchMedia() { return mql } } const { getToolInfo } = await import("./src/components/message-part.tsx") if (typeof getToolInfo !== "function") { console.error("getToolInfo is " + typeof getToolInfo) diff --git a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts new file mode 100644 index 00000000000..535162cfb11 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, afterEach } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { MarketplaceInstaller } from "../../src/services/marketplace/installer" +import { MarketplacePaths } from "../../src/services/marketplace/paths" + +const tmpDir = path.join(os.tmpdir(), `kilo-test-${Date.now()}`) + +class TestPaths extends MarketplacePaths { + override configPath(scope: "project" | "global", workspace?: string): string { + if (scope === "global") return path.join(tmpDir, "global", "kilo.json") + return path.join(tmpDir, "project", ".kilo", "kilo.json") + } + override skillsDir(scope: "project" | "global", workspace?: string): string { + return path.join(tmpDir, "skills") + } +} + +describe("MarketplaceInstaller MCP format normalization", () => { + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true }).catch(() => {}) + }) + + it("converts local command+args+env format to CLI format", async () => { + const installer = new MarketplaceInstaller(new TestPaths()) + const item = { + type: "mcp" as const, + id: "memory", + name: "Memory", + description: "test", + url: "https://example.com", + content: JSON.stringify({ + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + env: { MY_KEY: "my-value" }, + }), + } + const result = await installer.install(item, { target: "global" }, undefined) + expect(result.success).toBe(true) + + const written = JSON.parse(await fs.readFile(new TestPaths().configPath("global"), "utf-8")) + const mcp = written.mcp?.memory + expect(mcp.type).toBe("local") + expect(mcp.command).toEqual(["npx", "-y", "@modelcontextprotocol/server-memory"]) + expect(mcp.environment).toEqual({ MY_KEY: "my-value" }) + expect(mcp.args).toBeUndefined() + expect(mcp.env).toBeUndefined() + }) + + it("converts sse type to remote type", async () => { + const installer = new MarketplaceInstaller(new TestPaths()) + const item = { + type: "mcp" as const, + id: "myremote", + name: "Remote", + description: "test", + url: "https://example.com", + content: JSON.stringify({ + type: "sse", + url: "https://example.com/sse", + headers: { Authorization: "Bearer token" }, + }), + } + const result = await installer.install(item, { target: "global" }, undefined) + expect(result.success).toBe(true) + + const written = JSON.parse(await fs.readFile(new TestPaths().configPath("global"), "utf-8")) + const mcp = written.mcp?.myremote + expect(mcp.type).toBe("remote") + expect(mcp.url).toBe("https://example.com/sse") + expect(mcp.headers).toEqual({ Authorization: "Bearer token" }) + }) + + it("keeps already-normalized local format unchanged", async () => { + const installer = new MarketplaceInstaller(new TestPaths()) + const item = { + type: "mcp" as const, + id: "already", + name: "Already Done", + description: "test", + url: "https://example.com", + content: JSON.stringify({ + type: "local", + command: ["npx", "-y", "someserver"], + environment: { KEY: "val" }, + }), + } + const result = await installer.install(item, { target: "global" }, undefined) + expect(result.success).toBe(true) + + const written = JSON.parse(await fs.readFile(new TestPaths().configPath("global"), "utf-8")) + const mcp = written.mcp?.already + expect(mcp).toEqual({ type: "local", command: ["npx", "-y", "someserver"], environment: { KEY: "val" } }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/permission-description.test.ts b/packages/kilo-vscode/tests/unit/permission-description.test.ts new file mode 100644 index 00000000000..34d6742f1fa --- /dev/null +++ b/packages/kilo-vscode/tests/unit/permission-description.test.ts @@ -0,0 +1,207 @@ +import { describe, test, expect } from "bun:test" +import { + describePatterns, + resolveLabel, + TOOL_LABEL_KEYS, +} from "../../webview-ui/src/components/chat/permission-dock-utils" + +// Mock t() that returns the English label for known keys, or the key itself +const labels: Record = { + "ui.permission.toolLabel.read": "Read", + "ui.permission.toolLabel.edit": "Edit", + "ui.permission.toolLabel.write": "Write", + "ui.permission.toolLabel.patch": "Patch", + "ui.permission.toolLabel.globSearch": "Glob Search", + "ui.permission.toolLabel.grepSearch": "Grep Search", + "ui.permission.toolLabel.webSearch": "Web Search", + "ui.permission.toolLabel.list": "List", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.externalDirectory": "Read External Directory", + "ui.permission.toolLabel.webFetch": "Web Fetch", + "ui.permission.toolLabel.codeSearch": "Code Search", + "ui.permission.toolLabel.todoRead": "Todo Read", + "ui.permission.toolLabel.todoWrite": "Todo Write", + "ui.permission.toolLabel.task": "Task", + "ui.permission.toolLabel.skill": "Skill", + "ui.permission.toolLabel.lsp": "LSP", +} +const t = (key: string) => labels[key] ?? key + +describe("describePatterns", () => { + test("returns null when patterns is empty", () => { + expect(describePatterns("read", [], t)).toBeNull() + }) + + test("returns null when only wildcard pattern", () => { + expect(describePatterns("read", ["*"], t)).toBeNull() + }) + + test("returns null when multiple wildcards", () => { + expect(describePatterns("read", ["*", "*"], t)).toBeNull() + }) + + test("single pattern returns single kind with label", () => { + const result = describePatterns("read", ["src/app.ts"], t) + expect(result).toEqual({ kind: "single", text: "Read src/app.ts" }) + }) + + test("single pattern filters out wildcards", () => { + const result = describePatterns("edit", ["*", "src/index.ts"], t) + expect(result).toEqual({ kind: "single", text: "Edit src/index.ts" }) + }) + + test("multiple patterns returns multi kind", () => { + const result = describePatterns("read", ["src/app.ts", "src/index.ts"], t) + expect(result).toEqual({ kind: "multi", title: "Read:", paths: ["src/app.ts", "src/index.ts"] }) + }) + + test("multiple patterns filters out wildcards", () => { + const result = describePatterns("edit", ["*", "src/a.ts", "src/b.ts"], t) + expect(result).toEqual({ kind: "multi", title: "Edit:", paths: ["src/a.ts", "src/b.ts"] }) + }) + + test("edit tool uses Edit label", () => { + const result = describePatterns("edit", ["file.ts"], t) + expect(result).toEqual({ kind: "single", text: "Edit file.ts" }) + }) + + test("write tool uses Write label", () => { + const result = describePatterns("write", ["file.ts"], t) + expect(result).toEqual({ kind: "single", text: "Write file.ts" }) + }) + + test("multiedit tool uses Edit label", () => { + const result = describePatterns("multiedit", ["file.ts"], t) + expect(result).toEqual({ kind: "single", text: "Edit file.ts" }) + }) + + test("external_directory uses Read External Directory label", () => { + const result = describePatterns("external_directory", ["/home/user/project/*"], t) + expect(result).toEqual({ kind: "single", text: "Read External Directory /home/user/project/*" }) + }) + + test("glob tool uses Glob Search label", () => { + const result = describePatterns("glob", ["src/**/*.ts"], t) + expect(result).toEqual({ kind: "single", text: "Glob Search src/**/*.ts" }) + }) + + test("unknown tool uses raw name as label", () => { + const result = describePatterns("custom_tool", ["some/path"], t) + expect(result).toEqual({ kind: "single", text: "custom_tool some/path" }) + }) + + test("websearch uses Web Search label", () => { + const result = describePatterns("websearch", ["query"], t) + expect(result).toEqual({ kind: "single", text: "Web Search query" }) + }) + + test("TOOL_LABEL_KEYS maps all expected tools", () => { + const expected: Record = { + read: "ui.permission.toolLabel.read", + edit: "ui.permission.toolLabel.edit", + write: "ui.permission.toolLabel.write", + patch: "ui.permission.toolLabel.patch", + multiedit: "ui.permission.toolLabel.edit", + glob: "ui.permission.toolLabel.globSearch", + grep: "ui.permission.toolLabel.grepSearch", + list: "ui.permission.toolLabel.list", + bash: "ui.permission.toolLabel.bash", + external_directory: "ui.permission.toolLabel.externalDirectory", + webfetch: "ui.permission.toolLabel.webFetch", + websearch: "ui.permission.toolLabel.webSearch", + codesearch: "ui.permission.toolLabel.codeSearch", + todoread: "ui.permission.toolLabel.todoRead", + todowrite: "ui.permission.toolLabel.todoWrite", + task: "ui.permission.toolLabel.task", + skill: "ui.permission.toolLabel.skill", + lsp: "ui.permission.toolLabel.lsp", + } + expect(TOOL_LABEL_KEYS).toEqual(expected) + }) + + test("every i18n key in TOOL_LABEL_KEYS has a mock translation", () => { + const keys = new Set(Object.values(TOOL_LABEL_KEYS)) + for (const key of keys) { + expect(labels[key]).toBeDefined() + } + }) + + test("multi preserves pattern order", () => { + const result = describePatterns("write", ["z.ts", "a.ts", "m.ts"], t) + expect(result).toEqual({ kind: "multi", title: "Write:", paths: ["z.ts", "a.ts", "m.ts"] }) + }) + + test("grep tool uses Grep Search label", () => { + const result = describePatterns("grep", ["TODO"], t) + expect(result).toEqual({ kind: "single", text: "Grep Search TODO" }) + }) + + test("list tool uses List label", () => { + const result = describePatterns("list", ["src/"], t) + expect(result).toEqual({ kind: "single", text: "List src/" }) + }) + + test("webfetch tool uses Web Fetch label", () => { + const result = describePatterns("webfetch", ["https://example.com"], t) + expect(result).toEqual({ kind: "single", text: "Web Fetch https://example.com" }) + }) + + test("patch tool uses Patch label", () => { + const result = describePatterns("patch", ["file.ts"], t) + expect(result).toEqual({ kind: "single", text: "Patch file.ts" }) + }) + + test("task tool uses Task label", () => { + const result = describePatterns("task", ["run-tests"], t) + expect(result).toEqual({ kind: "single", text: "Task run-tests" }) + }) + + test("lsp tool uses LSP label", () => { + const result = describePatterns("lsp", ["diagnostics"], t) + expect(result).toEqual({ kind: "single", text: "LSP diagnostics" }) + }) +}) + +describe("resolveLabel", () => { + test("returns translated label for known tool", () => { + expect(resolveLabel("read", t)).toBe("Read") + }) + + test("returns translated label for edit", () => { + expect(resolveLabel("edit", t)).toBe("Edit") + }) + + test("multiedit resolves to Edit", () => { + expect(resolveLabel("multiedit", t)).toBe("Edit") + }) + + test("returns raw tool name for unknown tool", () => { + expect(resolveLabel("unknown_tool", t)).toBe("unknown_tool") + }) + + test("returns translated label for all mapped tools", () => { + const expected: Record = { + read: "Read", + edit: "Edit", + write: "Write", + patch: "Patch", + multiedit: "Edit", + glob: "Glob Search", + grep: "Grep Search", + list: "List", + bash: "Bash", + external_directory: "Read External Directory", + webfetch: "Web Fetch", + websearch: "Web Search", + codesearch: "Code Search", + todoread: "Todo Read", + todowrite: "Todo Write", + task: "Task", + skill: "Skill", + lsp: "LSP", + } + for (const [tool, label] of Object.entries(expected)) { + expect(resolveLabel(tool, t)).toBe(label) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index bb018596db3..bc314566986 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -95,6 +95,13 @@ describe("sanitizeReviewComments", () => { expect(result).toEqual([]) }) + it("preserves comments when diff is summarized", () => { + const c = comment({ file: "a.ts", line: 5, side: "additions" }) + const d = { ...diff("a.ts", "", ""), summarized: true } + const result = sanitizeReviewComments([c], [d]) + expect(result).toEqual([c]) + }) + it("returns all when all comments are valid", () => { const comments = [ comment({ file: "a.ts", line: 1 }), diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png index 41e2ccc8b50..8e0743a4bdf 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38ef5c149656fd4a9540b93dfc84a2f2661b3ef8e1c42385c7bb9f5f24daf82e -size 11710 +oid sha256:e90cdf2e9c001f9e7654b53fe09206f36dd7d0ef291d3c149106c43fc08c0451 +size 12365 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png index e3da1f8b30a..7520a6ee1e8 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bcafa00cb615abbf253220f3b1c3163056bd18e1f5bf9632bc7229738936eff6 -size 4311 +oid sha256:697c67e9a202684ba83fa894a0d16e6c60da7363c9f125b3d32ccea4157cc1e5 +size 4346 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png index be7adac4742..21b51fd2228 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:121124327b2f6c5080d66a448138b8221e3d31aeb22dbf4770621f58057624d5 -size 13439 +oid sha256:52722d7eeca414000bac9be9e25a842acf162bbf939bbe603221017bbe37a0ee +size 13477 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png index a45de7ff4c4..a1632db2bcd 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52f85f9f63eccd6217f37616c525df311e4d0a558547ae127366a7388e859dee -size 8395 +oid sha256:f8fd2c7e8e3d82c5ef99ab2e248f35dd175ef9d7f24c2b84b2a73dcdf50e3b37 +size 8104 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-bash-many-rules-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-bash-many-rules-chromium-linux.png index c716125e0a7..acf5d2d044c 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-bash-many-rules-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-bash-many-rules-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:634905d003a16d46218bd9feb7147814cec6d2292ca05271d50d490cafb252ac -size 15343 +oid sha256:0909d511ec9c8a0b456ebcd7dca7755c0f7148043892b45d943d95ac2e7d891c +size 15985 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png index 2ae1f21dab6..acf5d2d044c 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7701ac85943cc6d85964b26c4c575c17bd7a1dc6fc7a2714780bfb3f64e3b192 -size 16441 +oid sha256:0909d511ec9c8a0b456ebcd7dca7755c0f7148043892b45d943d95ac2e7d891c +size 15985 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-edit-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-edit-chromium-linux.png index 3d4432de953..2862d25afc2 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-edit-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-edit-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7b50c8b197f28b187193ede93643d0ca1b072e7cc5fbcf6f59b2cc39230a097 -size 15331 +oid sha256:36d371115e898752d566502cae5815c9a9719be64d9b5bd9f6612c0ed8a1d114 +size 15546 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-external-dir-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-external-dir-chromium-linux.png index aec6851307f..878d15c4706 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-external-dir-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-external-dir-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eed2001515bbe863fde713de6f70e3de179b4f996df2d96a7229402fa28e5a14 -size 15146 +oid sha256:0268de1b89b0af37f0eca8eb4c6244b9d49c1665b92c4402432d8efa0615e0a9 +size 15590 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-subagent-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-subagent-chromium-linux.png index 8fab9391238..640eaf22ada 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-subagent-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-subagent-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d7742ac24a7b161002e1090ef35334119bce65d321f432200a1115fd32b6cc1 -size 12774 +oid sha256:3d453374c4a8742a4730811356a1a95f75071d0dcc1f53eb8eaac3a6e6ad4d6b +size 13562 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-todo-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-todo-chromium-linux.png index 4f7db871097..588f5fdd2cf 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-todo-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-todo-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79406972f721c839641236b25e159ba81c99ad446a11c84486b23667f2984e3a -size 13019 +oid sha256:34965c1e34d6186515fd826abc43d68d74e53484e40b4b1ff04b7b9d82a6f94f +size 13291 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-websearch-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-websearch-chromium-linux.png index e77abce30fc..6336b90f9cd 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-websearch-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-websearch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d08fdef4a13e0b838f3843c61a2e1c7ddea9ef45089d8d6847ac85b0fe8bac6d -size 12957 +oid sha256:62fbc073856f73a4b25deceef1e0af3e711ec413ef4b9ec2e300fef8d98dc538 +size 13113 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-write-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-write-chromium-linux.png index 7da6df30475..415c576791d 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-write-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-write-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82f700518cfbce245da97a05709e63bb54e23ad83939cec81ced43c4a90b910b -size 14080 +oid sha256:2113bfbfbfa54af9cb7d94724edbb361a48bf3501270d7e692df9e4b6752d4ed +size 14222 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png index bd0a3d24e02..cb377edee0f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e51b88fada85811d734dd451b7c3c22cb5b4f9eb92898b613e5c8e68afac4d42 -size 5053 +oid sha256:d1c9cb376e22c7e0073f36eacf7e6c57b4ffdf78fbd432df4e26d63ddabec255 +size 5052 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-completed-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-completed-chromium-linux.png index 5d43a5e77bf..2f51cd2e66b 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-completed-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-completed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74d53efec74b56fcd689b1b2de4facb0570306995a6f5451d38e8303b65bbcc6 -size 6187 +oid sha256:3c6b518b2a350b5718fe9b76efafa4069298927ccbc81ff190dd5e09d4db4f51 +size 6130 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-with-permission-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-with-permission-chromium-linux.png index b4784977edb..a244deeaa87 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-with-permission-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-with-permission-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd4ecc915fbb6931b742587e97aeaf26aadb88213c2b98e9bda9a6ff3f668c88 -size 13856 +oid sha256:3a35c54af1c6d5f89fefcb651ef45deb127e1862f848560183abe206f99223ae +size 14179 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png index b1f51e7cdea..e6a6974981f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce16f0acdd7d82bb2b1bfd993e015cf89f1d20d50d2c045a6ad08fd4a6382c50 -size 8931 +oid sha256:202f955dc54c22a54c2674b98e65025cf6fedc5a3df6bcf45c5c6cd268163ab6 +size 8144 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-skill-card-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-skill-card-chromium-linux.png index 86b45cb700e..0518b50bb25 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-skill-card-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-skill-card-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d5400d9db070953fcbf4cc3f8a4968b80c5bccb1a67ee63a463aa0539de1f41 -size 11190 +oid sha256:50c969a3ebc78cc3058ed6fa5d2af9090cf939dfbf677fa4a8ad4b8078f27245 +size 11092 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-skill-card-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-skill-card-chromium-linux.png index 754e3f2927a..0a3401242ee 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-skill-card-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-skill-card-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7bc6061e131b91cd0ce7f40c5e2e4d1d1ef458511ea221a205d5538392805294 -size 9735 +oid sha256:223653f646439a4003d415347357748ce89de149c4518eb3f29f6e5892702772 +size 9583 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-installed-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-installed-chromium-linux.png index b7d88719025..d304537721d 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-installed-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-installed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:782c1fae4bed19825b56570e573d09cc80aa2180ca5c6a4c495867f207d95beb -size 54728 +oid sha256:c35bc13f78745ab41bf39e732c3945578fc2cb2cc47ef231e51b1357bc0b2817 +size 54160 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-items-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-items-chromium-linux.png index 899e1a2c4ad..0bb9c058098 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-items-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d309302b177f97f198a2b889c96d08cf4578d219043d48a9d0251460be411c4 -size 52039 +oid sha256:52c6eeef2d393b159fe9e88a9d283308d969b7311fe68ea23667561d21a2ed87 +size 51338 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 648e1c9fdb9..679abf1995f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -13,7 +13,7 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" import type { WorktreeFileDiff } from "../src/types/messages" import { useLanguage } from "../src/context/language" -import { getDirectory, getFilename, sanitizeReviewComments, type ReviewComment } from "./review-comments" +import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" import { buildReviewAnnotation, type AnnotationLabels, type AnnotationMeta } from "./review-annotations" import { LONG_DIFF_MARKER_FILE_COUNT, initialOpenFiles, isLargeDiffFile } from "./diff-open-policy" import { DiffEndMarker } from "./DiffEndMarker" @@ -220,7 +220,7 @@ export const DiffPanel: Component = (props) => { return } const content = currentDraft.side === "deletions" ? diff.before : diff.after - const max = content.length === 0 ? 0 : content.split("\n").length + const max = lineCount(content) if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 7ab00c4878e..ff9f4ab7ae2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -15,7 +15,7 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { useLanguage } from "../src/context/language" import { FileTree } from "./FileTree" -import { getDirectory, getFilename, sanitizeReviewComments, type ReviewComment } from "./review-comments" +import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" import { buildReviewAnnotation, type AnnotationLabels, type AnnotationMeta } from "./review-annotations" import { LONG_DIFF_MARKER_FILE_COUNT, initialOpenFiles, isLargeDiffFile } from "./diff-open-policy" import { DiffEndMarker } from "./DiffEndMarker" @@ -226,7 +226,7 @@ export const FullScreenDiffView: Component = (props) => return } const content = currentDraft.side === "deletions" ? diff.before : diff.after - const max = content.length === 0 ? 0 : content.split("\n").length + const max = lineCount(content) if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts index c043363c7c1..7301297b1ae 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-comments.ts @@ -4,9 +4,11 @@ import { formatReviewCommentMarkdown, formatReviewCommentsMarkdown } from "../sr export type { ReviewComment } export { formatReviewCommentsMarkdown } -function lineCount(text: string): number { +export function lineCount(text: string): number { if (text.length === 0) return 0 - return text.split("\n").length + let n = 1 + for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) n++ + return n } export function getDirectory(path: string): string { @@ -20,10 +22,21 @@ export function getFilename(path: string): string { } export function extractLines(content: string, start: number, end: number): string { - return content - .split("\n") - .slice(start - 1, end) - .join("\n") + let line = 1 + let i = 0 + while (line < start && i < content.length) { + if (content.charCodeAt(i) === 10) line++ + i++ + } + const begin = i + while (i < content.length) { + if (content.charCodeAt(i) === 10) { + if (line >= end) return content.slice(begin, i) + line++ + } + i++ + } + return content.slice(begin, i) } export function sanitizeReviewComments(comments: ReviewComment[], diffs: WorktreeFileDiff[]): ReviewComment[] { @@ -32,6 +45,7 @@ export function sanitizeReviewComments(comments: ReviewComment[], diffs: Worktre const diff = map.get(comment.file) if (!diff) return false const content = comment.side === "deletions" ? diff.before : diff.after + if (diff.summarized === true) return true const max = lineCount(content) if (comment.line < 1) return false if (comment.line > max) return false diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 451805f1130..320b3f49975 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -42,7 +42,6 @@ function isRenderable(part: SDKPart): boolean { interface AssistantMessageProps { message: SDKAssistantMessage showAssistantCopyPartID?: string | null - turnDurationMs?: number } function TodoToolCard(props: { part: ToolPart }) { @@ -92,7 +91,6 @@ export const AssistantMessage: Component = (props) => { part={part} message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} - turnDurationMs={props.turnDurationMs} /> } > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index ff92f454fbb..aeade89de78 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -7,10 +7,9 @@ * For bash, the hierarchical rules from metadata.rules are shown. * For other tools, the always array is shown so users can configure per-tool permissions. * The command buttons (Deny / Run) control the current command. - * When all rules are toggled ✓, the command auto-runs. */ -import { Component, For, Show, createSignal } from "solid-js" +import { Component, For, Show, createMemo, createSignal } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { DockPrompt } from "@kilocode/kilo-ui/dock-prompt" import { Icon } from "@kilocode/kilo-ui/icon" @@ -18,7 +17,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import { useConfig } from "../../context/config" -import { savedRuleStates, type RuleDecision } from "./permission-dock-utils" +import { describePatterns, resolveLabel, savedRuleStates, type RuleDecision } from "./permission-dock-utils" import type { PermissionRequest } from "../../types/messages" let rulesExpandedPreference = false @@ -42,6 +41,9 @@ export const PermissionDock: Component<{ const cmd = props.request.args?.command return typeof cmd === "string" ? cmd : undefined } + const description = createMemo(() => + command() ? null : describePatterns(props.request.toolName, props.request.patterns, language.t), + ) // Pre-populate toggle states from existing config rules so previously // approved/denied patterns show their saved state immediately. @@ -76,12 +78,6 @@ export const PermissionDock: Component<{ const next = current === decision ? "pending" : decision const updated = { ...decisions(), [index]: next } setDecisions(updated) - - const total = rules().length - const count = Object.values(updated).filter((d) => d === "approved").length - if (count === total && total > 0) { - props.onDecide("once", [...rules()], []) - } } const decision = (index: number): RuleDecision => decisions()[index] ?? "pending" @@ -103,7 +99,8 @@ export const PermissionDock: Component<{ return value } - const subtitle = () => (fromChild() ? `${props.request.toolName} (subagent)` : props.request.toolName) + const title = () => + fromChild() ? language.t("notification.permission.titleSubagent") : language.t("notification.permission.title") return ( -
- {language.t("notification.permission.title")} - {subtitle()} -
+
{title()}
} footer={ @@ -167,8 +161,13 @@ export const PermissionDock: Component<{
- {props.request.toolName} - {label(rule)} + + {command() + ? label(rule) + : rule === "*" + ? resolveLabel(props.request.toolName, language.t) + : `${resolveLabel(props.request.toolName, language.t)} ${rule}`} +
)} @@ -181,9 +180,18 @@ export const PermissionDock: Component<{ > {(cmd) => {cmd()}} - -
{toolDescription()}
-
+ {(() => { + const desc = description() + if (!desc) + return !command() && toolDescription() ?
{toolDescription()}
: null + if (desc.kind === "single") return
{desc.text}
+ return ( +
+ {desc.title} + {(path) => {path}} +
+ ) + })()}
+ +
+ 0} fallback={
No commands found
}> + + {(cmd, idx) => ( +
{ + e.preventDefault() + if (textareaRef) slash.select(cmd, textareaRef, setText, adjustHeight) + }} + onMouseEnter={() => slash.setIndex(idx())} + > + /{cmd.name} + + {cmd.description} + +
+ )} +
+
+
+
0}>
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 2d7bab29888..47b92132749 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -9,7 +9,7 @@ import { Component, createEffect, createMemo, For, Show } from "solid-js" import { ToolRegistry, ToolProps, getToolInfo } from "@kilocode/kilo-ui/message-part" -import { BasicTool } from "@kilocode/kilo-ui/basic-tool" +import { ToolCall } from "@kilocode/kilo-ui/basic-tool" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { useData } from "@kilocode/kilo-ui/context/data" @@ -112,7 +112,7 @@ const TaskToolRenderer: Component = (props) => { return (
- +
@@ -139,7 +139,7 @@ const TaskToolRenderer: Component = (props) => {
-
+
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index c1092f1eaf5..83a1dd91337 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -11,7 +11,7 @@ import { Component, createMemo, For, Show, createSignal, createEffect, on } from "solid-js" import { Dynamic } from "solid-js/web" -import { Message, UserMessageDisplay } from "@kilocode/kilo-ui/message-part" +import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part" import { Collapsible } from "@kilocode/kilo-ui/collapsible" import { Accordion } from "@kilocode/kilo-ui/accordion" import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" @@ -130,19 +130,6 @@ export const VscodeSessionTurn: Component = (props) => { ), ) - // Last turn duration (for text part meta) - const turnDurationMs = createMemo(() => { - const start = (message() as unknown as { time?: { created?: number } } | undefined)?.time?.created - if (typeof start !== "number") return undefined - const end = assistantMessages().reduce((max, item) => { - const completed = item.time?.completed - if (typeof completed !== "number") return max - return max === undefined ? completed : Math.max(max, completed) - }, undefined) - if (typeof end !== "number" || end < start) return undefined - return end - start - }) - // Copy part ID — the last text part from the last assistant message const showAssistantCopyPartID = createMemo(() => { const msgs = assistantMessages() @@ -182,13 +169,7 @@ export const VscodeSessionTurn: Component = (props) => { 0}>
- {(msg) => ( - - )} + {(msg) => }
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts index 9aa3e6eff65..8d726db2901 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts @@ -17,3 +17,58 @@ export function savedRuleStates(rules: string[], saved: PermissionRule | undefin } return result } + +// --------------------------------------------------------------------------- +// Human-readable permission descriptions +// --------------------------------------------------------------------------- + +/** Maps tool names to their i18n key for the human-readable label. */ +export const TOOL_LABEL_KEYS: Record = { + read: "ui.permission.toolLabel.read", + edit: "ui.permission.toolLabel.edit", + write: "ui.permission.toolLabel.write", + patch: "ui.permission.toolLabel.patch", + multiedit: "ui.permission.toolLabel.edit", + glob: "ui.permission.toolLabel.globSearch", + grep: "ui.permission.toolLabel.grepSearch", + list: "ui.permission.toolLabel.list", + bash: "ui.permission.toolLabel.bash", + external_directory: "ui.permission.toolLabel.externalDirectory", + webfetch: "ui.permission.toolLabel.webFetch", + websearch: "ui.permission.toolLabel.webSearch", + codesearch: "ui.permission.toolLabel.codeSearch", + todoread: "ui.permission.toolLabel.todoRead", + todowrite: "ui.permission.toolLabel.todoWrite", + task: "ui.permission.toolLabel.task", + skill: "ui.permission.toolLabel.skill", + lsp: "ui.permission.toolLabel.lsp", +} + +export type PatternDescription = { kind: "single"; text: string } | { kind: "multi"; title: string; paths: string[] } + +/** Resolve the human-readable label for a tool (e.g. "Read", "Web Fetch"). */ +export function resolveLabel(tool: string, t: (key: string) => string): string { + const key = TOOL_LABEL_KEYS[tool] + return key ? t(key) : tool +} + +/** + * Build a human-readable description for a permission request's patterns. + * + * Returns null when there are no meaningful patterns to display (e.g. only "*"). + * For a single pattern: "Read src/app.ts" + * For multiple patterns: { title: "Read:", paths: ["src/app.ts", "src/index.ts"] } + */ +export function describePatterns( + tool: string, + patterns: string[], + t: (key: string) => string, +): PatternDescription | null { + const filtered = patterns.filter((p) => p !== "*") + if (filtered.length === 0) return null + + const key = TOOL_LABEL_KEYS[tool] + const label = key ? t(key) : tool + if (filtered.length === 1) return { kind: "single", text: `${label} ${filtered[0]}` } + return { kind: "multi", title: `${label}:`, paths: filtered } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index fb3115bad47..3b114ad1028 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -153,6 +153,7 @@ interface SessionContextValue { revertSession: (messageID: string) => void unrevertSession: () => void sendMessage: (text: string, providerID?: string, modelID?: string, files?: FileAttachment[]) => void + sendCommand: (command: string, args: string, providerID?: string, modelID?: string, files?: FileAttachment[]) => void abort: () => void compact: () => void respondToPermission: ( @@ -1118,6 +1119,40 @@ export const SessionProvider: ParentComponent = (props) => { } } + /** Create an optimistic user message + parts in the store so the UI updates instantly. */ + function addOptimistic(sid: string, messageID: string, text: string, files?: FileAttachment[]) { + const now = Date.now() + const temp: Message = { + id: messageID, + sessionID: sid, + role: "user", + createdAt: new Date(now).toISOString(), + time: { created: now }, + } + const pending = pendingOptimistic.get(sid) ?? new Set() + pending.add(messageID) + pendingOptimistic.set(sid, pending) + + const parts: Part[] = [] + if (text) { + parts.push({ type: "text" as const, id: Identifier.ascending("part"), messageID, text }) + } + for (const file of files ?? []) { + parts.push({ + type: "file" as const, + id: Identifier.ascending("part"), + messageID, + mime: file.mime, + url: file.url, + filename: file.filename, + }) + } + + setStore("messages", sid, (msgs = []) => [...msgs, temp]) + setStore("parts", messageID, parts) + queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll"))) + } + function sendMessage(text: string, providerID?: string, modelID?: string, files?: FileAttachment[]) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot send message: not connected") @@ -1144,43 +1179,7 @@ export const SessionProvider: ParentComponent = (props) => { } const sid = currentSessionID() - if (sid) { - const now = Date.now() - const temp: Message = { - id: messageID, - sessionID: sid, - role: "user", - createdAt: new Date(now).toISOString(), - time: { created: now }, - } - // Track this optimistic message so handleMessagesLoaded preserves it - const pending = pendingOptimistic.get(sid) ?? new Set() - pending.add(messageID) - pendingOptimistic.set(sid, pending) - - const parts: Part[] = [] - if (text) { - parts.push({ type: "text" as const, id: Identifier.ascending("part"), messageID, text }) - } - for (const file of files ?? []) { - parts.push({ - type: "file" as const, - id: Identifier.ascending("part"), - messageID, - mime: file.mime, - url: file.url, - filename: file.filename, - }) - } - - setStore("messages", sid, (msgs = []) => [...msgs, temp]) - setStore("parts", messageID, parts) - // The optimistic message is now in the DOM but the session status is - // still "idle" (the CLI backend hasn't started yet), so the auto-scroll - // ResizeObserver won't scroll on its own. Force scroll to bottom so the - // user's own message is immediately visible. - queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll"))) - } + if (sid) addOptimistic(sid, messageID, text, files) const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined @@ -1197,6 +1196,53 @@ export const SessionProvider: ParentComponent = (props) => { }) } + function sendCommand(command: string, args: string, providerID?: string, modelID?: string, files?: FileAttachment[]) { + if (!server.isConnected()) { + console.warn("[Kilo New] Cannot send command: not connected") + return + } + + // Cloud previews need import-then-command; post importAndSend with command metadata + const preview = cloudPreviewId() + if (preview) { + const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined + vscode.postMessage({ + type: "importAndSend", + cloudSessionId: preview, + text: `/${command} ${args}`.trim(), + messageID: Identifier.ascending("message"), + providerID, + modelID, + agent, + variant: currentVariant(), + files, + command, + commandArgs: args, + }) + return + } + + const messageID = Identifier.ascending("message") + const sid = currentSessionID() + + if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files) + + const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined + + vscode.postMessage({ + type: "sendCommand", + command, + arguments: args, + messageID, + sessionID: sid, + providerID, + modelID, + agent, + variant: currentVariant(), + files, + }) + } + function abort() { const sessionID = currentSessionID() if (!sessionID) { @@ -1539,6 +1585,7 @@ export const SessionProvider: ParentComponent = (props) => { revertSession, unrevertSession, sendMessage, + sendCommand, abort, compact, respondToPermission, diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts new file mode 100644 index 00000000000..ace9bed96af --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -0,0 +1,141 @@ +import { createSignal, onCleanup, onMount } from "solid-js" +import type { Accessor } from "solid-js" +import type { SlashCommandInfo, WebviewMessage, ExtensionMessage } from "../types/messages" + +export const SLASH_PATTERN = /^\/(\S*)$/ + +interface VSCodeContext { + postMessage: (message: WebviewMessage) => void + onMessage: (handler: (message: ExtensionMessage) => void) => () => void +} + +export interface SlashCommand { + results: Accessor + index: Accessor + show: Accessor + commands: Accessor + onInput: (val: string, cursor: number) => void + onKeyDown: ( + e: KeyboardEvent, + textarea: HTMLTextAreaElement | undefined, + setText: (text: string) => void, + onSelect?: () => void, + ) => boolean + select: ( + cmd: SlashCommandInfo, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => void + setIndex: (index: number) => void + close: () => void +} + +export function useSlashCommand(vscode: VSCodeContext): SlashCommand { + const [commands, setCommands] = createSignal([]) + const [query, setQuery] = createSignal(null) + const [index, setIndex] = createSignal(0) + + const show = () => query() !== null + + const results = () => { + const q = query() + if (q === null) return [] + const all = commands() + if (!q) return all + const lower = q.toLowerCase() + return all.filter((cmd) => cmd.name.toLowerCase().includes(lower) || cmd.description?.toLowerCase().includes(lower)) + } + + const unsubscribe = vscode.onMessage((message) => { + if (message.type !== "commandsLoaded") return + setCommands(message.commands) + }) + + onMount(() => { + vscode.postMessage({ type: "requestCommands" }) + }) + + onCleanup(() => { + unsubscribe() + }) + + const close = () => { + setQuery(null) + } + + const onInput = (val: string, cursor: number) => { + const before = val.substring(0, cursor) + const match = before.match(SLASH_PATTERN) + if (match) { + setQuery(match[1]) + setIndex(0) + } else { + close() + } + } + + const select = ( + cmd: SlashCommandInfo, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => { + const text = `/${cmd.name} ` + textarea.value = text + setText(text) + const pos = text.length + textarea.setSelectionRange(pos, pos) + textarea.focus() + close() + onSelect?.() + } + + const onKeyDown = ( + e: KeyboardEvent, + textarea: HTMLTextAreaElement | undefined, + setText: (text: string) => void, + onSelect?: () => void, + ): boolean => { + if (!show()) return false + + const filtered = results() + + if (e.key === "ArrowDown") { + e.preventDefault() + setIndex((i) => Math.min(i + 1, filtered.length - 1)) + return true + } + if (e.key === "ArrowUp") { + e.preventDefault() + setIndex((i) => Math.max(i - 1, 0)) + return true + } + if (e.key === "Enter" || e.key === "Tab") { + const cmd = filtered[index()] + if (!cmd) return false + e.preventDefault() + if (textarea) select(cmd, textarea, setText, onSelect) + return true + } + if (e.key === "Escape") { + e.preventDefault() + close() + return true + } + + return false + } + + return { + results, + index, + show, + commands, + onInput, + onKeyDown, + select, + setIndex, + close, + } +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 3dc4cde8d41..4200e5fa1d7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "ضغط الجلسة", "command.session.compact.description": "تلخيص الجلسة لتقليل حجم السياق", "command.session.fork": "تشعب من الرسالة", @@ -449,12 +446,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "ملف التكوين في {{path}} غير صالح: {{message}}", "notification.permission.title": "مطلوب إذن", + "notification.permission.titleSubagent": "مطلوب إذن (وكيل فرعي)", "notification.permission.description": "{{sessionTitle}} في {{projectName}} يحتاج إلى إذن", "ui.permission.manageAutoApprove": "إدارة قواعد الموافقة التلقائية", "ui.permission.rule.addToAllowed": "أضف إلى قائمة المسموح", "ui.permission.rule.removeFromAllowed": "أزل من قائمة المسموح", "ui.permission.rule.addToDenied": "أضف إلى قائمة المرفوض", "ui.permission.rule.removeFromDenied": "أزل من قائمة المرفوض", + "ui.permission.toolLabel.read": "قراءة", + "ui.permission.toolLabel.edit": "تعديل", + "ui.permission.toolLabel.write": "كتابة", + "ui.permission.toolLabel.patch": "ترقيع", + "ui.permission.toolLabel.globSearch": "بحث Glob", + "ui.permission.toolLabel.grepSearch": "بحث Grep", + "ui.permission.toolLabel.webSearch": "بحث ويب", + "ui.permission.toolLabel.list": "قائمة", + "ui.permission.toolLabel.externalDirectory": "قراءة دليل خارجي", + "ui.permission.toolLabel.webFetch": "جلب ويب", + "ui.permission.toolLabel.task": "مهمة", + "ui.permission.toolLabel.skill": "مهارة", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "قراءة المهام", + "ui.permission.toolLabel.todoWrite": "كتابة المهام", + "ui.permission.toolLabel.codeSearch": "بحث الكود", "notification.question.title": "سؤال", "notification.question.description": "{{sessionTitle}} في {{projectName}} لديه سؤال", "notification.action.goToSession": "انتقل إلى الجلسة", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 6b7d3b08992..b7e4443a15a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Compactar sessão", "command.session.compact.description": "Resumir a sessão para reduzir o tamanho do contexto", "command.session.fork": "Bifurcar da mensagem", @@ -451,12 +448,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Arquivo de configuração em {{path}} é inválido: {{message}}", "notification.permission.title": "Permissão necessária", + "notification.permission.titleSubagent": "Permissão necessária (subagente)", "notification.permission.description": "{{sessionTitle}} em {{projectName}} precisa de permissão", "ui.permission.manageAutoApprove": "Gerenciar regras de aprovação automática", "ui.permission.rule.addToAllowed": "Adicionar à lista de permitidos", "ui.permission.rule.removeFromAllowed": "Remover da lista de permitidos", "ui.permission.rule.addToDenied": "Adicionar à lista de negados", "ui.permission.rule.removeFromDenied": "Remover da lista de negados", + "ui.permission.toolLabel.read": "Ler", + "ui.permission.toolLabel.edit": "Editar", + "ui.permission.toolLabel.write": "Escrever", + "ui.permission.toolLabel.patch": "Corrigir", + "ui.permission.toolLabel.globSearch": "Pesquisa Glob", + "ui.permission.toolLabel.grepSearch": "Pesquisa Grep", + "ui.permission.toolLabel.webSearch": "Pesquisa Web", + "ui.permission.toolLabel.list": "Listar", + "ui.permission.toolLabel.externalDirectory": "Ler diretório externo", + "ui.permission.toolLabel.webFetch": "Busca Web", + "ui.permission.toolLabel.task": "Tarefa", + "ui.permission.toolLabel.skill": "Habilidade", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Ler Tarefas", + "ui.permission.toolLabel.todoWrite": "Escrever Tarefas", + "ui.permission.toolLabel.codeSearch": "Pesquisa de Código", "notification.question.title": "Pergunta", "notification.question.description": "{{sessionTitle}} em {{projectName}} tem uma pergunta", "notification.action.goToSession": "Ir para sessão", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 8443bcdd8f4..28e1585508e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Sažmi sesiju", "command.session.compact.description": "Sažmi sesiju kako bi se smanjio kontekst", "command.session.fork": "Fork iz poruke", @@ -455,12 +452,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Konfiguracijska datoteka na {{path}} nije ispravna: {{message}}", "notification.permission.title": "Potrebna dozvola", + "notification.permission.titleSubagent": "Potrebna dozvola (podagent)", "notification.permission.description": "{{sessionTitle}} u {{projectName}} traži dozvolu", "ui.permission.manageAutoApprove": "Upravljanje pravilima automatskog odobravanja", "ui.permission.rule.addToAllowed": "Dodaj na listu dozvoljenih", "ui.permission.rule.removeFromAllowed": "Ukloni sa liste dozvoljenih", "ui.permission.rule.addToDenied": "Dodaj na listu odbijenih", "ui.permission.rule.removeFromDenied": "Ukloni sa liste odbijenih", + "ui.permission.toolLabel.read": "Čitaj", + "ui.permission.toolLabel.edit": "Uredi", + "ui.permission.toolLabel.write": "Piši", + "ui.permission.toolLabel.patch": "Zakrpi", + "ui.permission.toolLabel.globSearch": "Glob pretraga", + "ui.permission.toolLabel.grepSearch": "Grep pretraga", + "ui.permission.toolLabel.webSearch": "Web pretraga", + "ui.permission.toolLabel.list": "Izlistaj", + "ui.permission.toolLabel.externalDirectory": "Čitaj vanjski direktorij", + "ui.permission.toolLabel.webFetch": "Web dohvat", + "ui.permission.toolLabel.task": "Zadatak", + "ui.permission.toolLabel.skill": "Vještina", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Čitanje liste zadataka", + "ui.permission.toolLabel.todoWrite": "Ažuriranje liste zadataka", + "ui.permission.toolLabel.codeSearch": "Pretraga koda", "notification.question.title": "Pitanje", "notification.question.description": "{{sessionTitle}} u {{projectName}} ima pitanje", "notification.action.goToSession": "Idi na sesiju", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index fa462464d47..e7e4efbc7f2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Komprimér session", "command.session.compact.description": "Opsummer sessionen for at reducere kontekststørrelsen", "command.session.fork": "Forgren fra besked", @@ -452,12 +449,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Konfigurationsfil på {{path}} er ugyldig: {{message}}", "notification.permission.title": "Tilladelse påkrævet", + "notification.permission.titleSubagent": "Tilladelse påkrævet (underagent)", "notification.permission.description": "{{sessionTitle}} i {{projectName}} kræver tilladelse", "ui.permission.manageAutoApprove": "Administrer regler for automatisk godkendelse", "ui.permission.rule.addToAllowed": "Tilføj til tilladelseslisten", "ui.permission.rule.removeFromAllowed": "Fjern fra tilladelseslisten", "ui.permission.rule.addToDenied": "Tilføj til afvisningslisten", "ui.permission.rule.removeFromDenied": "Fjern fra afvisningslisten", + "ui.permission.toolLabel.read": "Læs", + "ui.permission.toolLabel.edit": "Rediger", + "ui.permission.toolLabel.write": "Skriv", + "ui.permission.toolLabel.patch": "Patch", + "ui.permission.toolLabel.globSearch": "Glob-søgning", + "ui.permission.toolLabel.grepSearch": "Grep-søgning", + "ui.permission.toolLabel.webSearch": "Websøgning", + "ui.permission.toolLabel.list": "List", + "ui.permission.toolLabel.externalDirectory": "Læs ekstern mappe", + "ui.permission.toolLabel.webFetch": "Webhentning", + "ui.permission.toolLabel.task": "Opgave", + "ui.permission.toolLabel.skill": "Færdighed", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Læs To-do", + "ui.permission.toolLabel.todoWrite": "Skriv To-do", + "ui.permission.toolLabel.codeSearch": "Kodesøgning", "notification.question.title": "Spørgsmål", "notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørgsmål", "notification.action.goToSession": "Gå til session", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index ae726e8982c..48d9530dd6b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -87,9 +87,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Sitzung komprimieren", "command.session.compact.description": "Sitzung zusammenfassen, um die Kontextgröße zu reduzieren", "command.session.fork": "Von Nachricht abzweigen", @@ -459,12 +456,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Konfigurationsdatei unter {{path}} ist ungültig: {{message}}", "notification.permission.title": "Berechtigung erforderlich", + "notification.permission.titleSubagent": "Berechtigung erforderlich (Subagent)", "notification.permission.description": "{{sessionTitle}} in {{projectName}} benötigt Berechtigung", "ui.permission.manageAutoApprove": "Regeln für automatische Genehmigung verwalten", "ui.permission.rule.addToAllowed": "Zur Erlaubt-Liste hinzufügen", "ui.permission.rule.removeFromAllowed": "Aus der Erlaubt-Liste entfernen", "ui.permission.rule.addToDenied": "Zur Verweigert-Liste hinzufügen", "ui.permission.rule.removeFromDenied": "Aus der Verweigert-Liste entfernen", + "ui.permission.toolLabel.read": "Lesen", + "ui.permission.toolLabel.edit": "Bearbeiten", + "ui.permission.toolLabel.write": "Schreiben", + "ui.permission.toolLabel.patch": "Patchen", + "ui.permission.toolLabel.globSearch": "Glob-Suche", + "ui.permission.toolLabel.grepSearch": "Grep-Suche", + "ui.permission.toolLabel.webSearch": "Websuche", + "ui.permission.toolLabel.list": "Auflisten", + "ui.permission.toolLabel.externalDirectory": "Externes Verzeichnis lesen", + "ui.permission.toolLabel.webFetch": "Web-Abruf", + "ui.permission.toolLabel.task": "Aufgabe", + "ui.permission.toolLabel.skill": "Fähigkeit", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Todo lesen", + "ui.permission.toolLabel.todoWrite": "Todo schreiben", + "ui.permission.toolLabel.codeSearch": "Code-Suche", "notification.question.title": "Frage", "notification.question.description": "{{sessionTitle}} in {{projectName}} hat eine Frage", "notification.action.goToSession": "Zur Sitzung gehen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 860699b6d70..17ad50f91aa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Compact session", "command.session.compact.description": "Summarize the session to reduce context size", "command.session.fork": "Fork from message", @@ -454,12 +451,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Config file at {{path}} is invalid: {{message}}", "notification.permission.title": "Permission required", + "notification.permission.titleSubagent": "Permission required (subagent)", "notification.permission.description": "{{sessionTitle}} in {{projectName}} needs permission", "ui.permission.manageAutoApprove": "Manage Auto-Approve Rules", "ui.permission.rule.addToAllowed": "Add to allowed list", "ui.permission.rule.removeFromAllowed": "Remove from allowed list", "ui.permission.rule.addToDenied": "Add to denied list", "ui.permission.rule.removeFromDenied": "Remove from denied list", + "ui.permission.toolLabel.read": "Read", + "ui.permission.toolLabel.edit": "Edit", + "ui.permission.toolLabel.write": "Write", + "ui.permission.toolLabel.patch": "Patch", + "ui.permission.toolLabel.globSearch": "Glob Search", + "ui.permission.toolLabel.grepSearch": "Grep Search", + "ui.permission.toolLabel.webSearch": "Web Search", + "ui.permission.toolLabel.list": "List", + "ui.permission.toolLabel.externalDirectory": "Read External Directory", + "ui.permission.toolLabel.webFetch": "Web Fetch", + "ui.permission.toolLabel.task": "Task", + "ui.permission.toolLabel.skill": "Skill", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Todo Read", + "ui.permission.toolLabel.todoWrite": "Todo Write", + "ui.permission.toolLabel.codeSearch": "Code Search", "notification.question.title": "Question", "notification.question.description": "{{sessionTitle}} in {{projectName}} has a question", "notification.action.goToSession": "Go to session", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index a401f445efe..d0ec332e334 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Compactar sesión", "command.session.compact.description": "Resumir la sesión para reducir el tamaño del contexto", "command.session.fork": "Bifurcar desde mensaje", @@ -454,12 +451,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "El archivo de configuración en {{path}} es inválido: {{message}}", "notification.permission.title": "Permiso requerido", + "notification.permission.titleSubagent": "Permiso requerido (subagente)", "notification.permission.description": "{{sessionTitle}} en {{projectName}} necesita permiso", "ui.permission.manageAutoApprove": "Gestionar reglas de aprobación automática", "ui.permission.rule.addToAllowed": "Añadir a la lista de permitidos", "ui.permission.rule.removeFromAllowed": "Quitar de la lista de permitidos", "ui.permission.rule.addToDenied": "Añadir a la lista de denegados", "ui.permission.rule.removeFromDenied": "Quitar de la lista de denegados", + "ui.permission.toolLabel.read": "Leer", + "ui.permission.toolLabel.edit": "Editar", + "ui.permission.toolLabel.write": "Escribir", + "ui.permission.toolLabel.patch": "Parchear", + "ui.permission.toolLabel.globSearch": "Búsqueda Glob", + "ui.permission.toolLabel.grepSearch": "Búsqueda Grep", + "ui.permission.toolLabel.webSearch": "Búsqueda Web", + "ui.permission.toolLabel.list": "Listar", + "ui.permission.toolLabel.externalDirectory": "Leer directorio externo", + "ui.permission.toolLabel.webFetch": "Obtener Web", + "ui.permission.toolLabel.task": "Tarea", + "ui.permission.toolLabel.skill": "Habilidad", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Leer Todo", + "ui.permission.toolLabel.todoWrite": "Escribir Todo", + "ui.permission.toolLabel.codeSearch": "Búsqueda de Código", "notification.question.title": "Pregunta", "notification.question.description": "{{sessionTitle}} en {{projectName}} tiene una pregunta", "notification.action.goToSession": "Ir a sesión", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index f736fe88a45..d07999034b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -84,9 +84,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Compacter la session", "command.session.compact.description": "Résumer la session pour réduire la taille du contexte", "command.session.fork": "Bifurquer à partir du message", @@ -459,12 +456,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Le fichier de configuration à {{path}} est invalide : {{message}}", "notification.permission.title": "Permission requise", + "notification.permission.titleSubagent": "Permission requise (sous-agent)", "notification.permission.description": "{{sessionTitle}} dans {{projectName}} a besoin d'une permission", "ui.permission.manageAutoApprove": "Gérer les règles d'approbation automatique", "ui.permission.rule.addToAllowed": "Ajouter à la liste des autorisés", "ui.permission.rule.removeFromAllowed": "Retirer de la liste des autorisés", "ui.permission.rule.addToDenied": "Ajouter à la liste des refusés", "ui.permission.rule.removeFromDenied": "Retirer de la liste des refusés", + "ui.permission.toolLabel.read": "Lire", + "ui.permission.toolLabel.edit": "Modifier", + "ui.permission.toolLabel.write": "Écrire", + "ui.permission.toolLabel.patch": "Patcher", + "ui.permission.toolLabel.globSearch": "Recherche Glob", + "ui.permission.toolLabel.grepSearch": "Recherche Grep", + "ui.permission.toolLabel.webSearch": "Recherche Web", + "ui.permission.toolLabel.list": "Lister", + "ui.permission.toolLabel.externalDirectory": "Lire répertoire externe", + "ui.permission.toolLabel.webFetch": "Récupération Web", + "ui.permission.toolLabel.task": "Tâche", + "ui.permission.toolLabel.skill": "Compétence", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Lire Todo", + "ui.permission.toolLabel.todoWrite": "Écrire Todo", + "ui.permission.toolLabel.codeSearch": "Recherche de code", "notification.question.title": "Question", "notification.question.description": "{{sessionTitle}} dans {{projectName}} a une question", "notification.action.goToSession": "Aller à la session", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 23249048b76..7f4312039cb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "セッションを圧縮", "command.session.compact.description": "セッションを要約してコンテキストサイズを削減", "command.session.fork": "メッセージからフォーク", @@ -450,12 +447,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "{{path}} の設定ファイルが無効です: {{message}}", "notification.permission.title": "権限が必要です", + "notification.permission.titleSubagent": "権限が必要です(サブエージェント)", "notification.permission.description": "{{projectName}} の {{sessionTitle}} が権限を必要としています", "ui.permission.manageAutoApprove": "自動承認ルールを管理", "ui.permission.rule.addToAllowed": "許可リストに追加", "ui.permission.rule.removeFromAllowed": "許可リストから削除", "ui.permission.rule.addToDenied": "拒否リストに追加", "ui.permission.rule.removeFromDenied": "拒否リストから削除", + "ui.permission.toolLabel.read": "読み取り", + "ui.permission.toolLabel.edit": "編集", + "ui.permission.toolLabel.write": "書き込み", + "ui.permission.toolLabel.patch": "パッチ", + "ui.permission.toolLabel.globSearch": "Glob検索", + "ui.permission.toolLabel.grepSearch": "Grep検索", + "ui.permission.toolLabel.webSearch": "Web検索", + "ui.permission.toolLabel.list": "一覧", + "ui.permission.toolLabel.externalDirectory": "外部ディレクトリ読み取り", + "ui.permission.toolLabel.webFetch": "Web取得", + "ui.permission.toolLabel.task": "タスク", + "ui.permission.toolLabel.skill": "スキル", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Todo読み込み", + "ui.permission.toolLabel.todoWrite": "Todo書き込み", + "ui.permission.toolLabel.codeSearch": "コード検索", "notification.question.title": "質問", "notification.question.description": "{{projectName}} の {{sessionTitle}} から質問があります", "notification.action.goToSession": "セッションへ移動", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 9f3684ca44f..2a202834896 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -87,9 +87,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "세션 압축", "command.session.compact.description": "컨텍스트 크기를 줄이기 위해 세션 요약", "command.session.fork": "메시지에서 분기", @@ -453,12 +450,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "{{path}}의 구성 파일이 유효하지 않습니다: {{message}}", "notification.permission.title": "권한 필요", + "notification.permission.titleSubagent": "권한 필요 (서브에이전트)", "notification.permission.description": "{{projectName}}의 {{sessionTitle}}에서 권한이 필요합니다", "ui.permission.manageAutoApprove": "자동 승인 규칙 관리", "ui.permission.rule.addToAllowed": "허용 목록에 추가", "ui.permission.rule.removeFromAllowed": "허용 목록에서 제거", "ui.permission.rule.addToDenied": "거부 목록에 추가", "ui.permission.rule.removeFromDenied": "거부 목록에서 제거", + "ui.permission.toolLabel.read": "읽기", + "ui.permission.toolLabel.edit": "편집", + "ui.permission.toolLabel.write": "쓰기", + "ui.permission.toolLabel.patch": "패치", + "ui.permission.toolLabel.globSearch": "Glob 검색", + "ui.permission.toolLabel.grepSearch": "Grep 검색", + "ui.permission.toolLabel.webSearch": "웹 검색", + "ui.permission.toolLabel.list": "목록", + "ui.permission.toolLabel.externalDirectory": "외부 디렉토리 읽기", + "ui.permission.toolLabel.webFetch": "웹 가져오기", + "ui.permission.toolLabel.task": "작업", + "ui.permission.toolLabel.skill": "스킬", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "할 일 읽기", + "ui.permission.toolLabel.todoWrite": "할 일 쓰기", + "ui.permission.toolLabel.codeSearch": "코드 검색", "notification.question.title": "질문", "notification.question.description": "{{projectName}}의 {{sessionTitle}}에서 질문이 있습니다", "notification.action.goToSession": "세션으로 이동", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index d1e5323418b..678aa48d0db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -86,9 +86,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Komprimer sesjon", "command.session.compact.description": "Oppsummer sesjonen for å redusere kontekststørrelsen", "command.session.fork": "Forgren fra melding", @@ -455,12 +452,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Konfigurasjonsfilen på {{path}} er ugyldig: {{message}}", "notification.permission.title": "Tillatelse påkrevd", + "notification.permission.titleSubagent": "Tillatelse påkrevd (underagent)", "notification.permission.description": "{{sessionTitle}} i {{projectName}} trenger tillatelse", "ui.permission.manageAutoApprove": "Administrer regler for automatisk godkjenning", "ui.permission.rule.addToAllowed": "Legg til i tillatt-listen", "ui.permission.rule.removeFromAllowed": "Fjern fra tillatt-listen", "ui.permission.rule.addToDenied": "Legg til i blokkert-listen", "ui.permission.rule.removeFromDenied": "Fjern fra blokkert-listen", + "ui.permission.toolLabel.read": "Les", + "ui.permission.toolLabel.edit": "Rediger", + "ui.permission.toolLabel.write": "Skriv", + "ui.permission.toolLabel.patch": "Patch", + "ui.permission.toolLabel.globSearch": "Glob-søk", + "ui.permission.toolLabel.grepSearch": "Grep-søk", + "ui.permission.toolLabel.webSearch": "Websøk", + "ui.permission.toolLabel.list": "List", + "ui.permission.toolLabel.externalDirectory": "Les ekstern mappe", + "ui.permission.toolLabel.webFetch": "Webhenting", + "ui.permission.toolLabel.task": "Oppgave", + "ui.permission.toolLabel.skill": "Ferdighet", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Les gjøremål", + "ui.permission.toolLabel.todoWrite": "Skriv gjøremål", + "ui.permission.toolLabel.codeSearch": "Kodesøk", "notification.question.title": "Spørsmål", "notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørsmål", "notification.action.goToSession": "Gå til sesjon", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index ba7cb425ab7..d2a964b7afd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Kompaktuj sesję", "command.session.compact.description": "Podsumuj sesję, aby zmniejszyć rozmiar kontekstu", "command.session.fork": "Rozwidlij od wiadomości", @@ -453,12 +450,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Plik konfiguracyjny w {{path}} jest nieprawidłowy: {{message}}", "notification.permission.title": "Wymagane uprawnienie", + "notification.permission.titleSubagent": "Wymagane uprawnienie (podagent)", "notification.permission.description": "{{sessionTitle}} w {{projectName}} potrzebuje uprawnienia", "ui.permission.manageAutoApprove": "Zarządzaj regułami automatycznego zatwierdzania", "ui.permission.rule.addToAllowed": "Dodaj do listy dozwolonych", "ui.permission.rule.removeFromAllowed": "Usuń z listy dozwolonych", "ui.permission.rule.addToDenied": "Dodaj do listy zablokowanych", "ui.permission.rule.removeFromDenied": "Usuń z listy zablokowanych", + "ui.permission.toolLabel.read": "Czytaj", + "ui.permission.toolLabel.edit": "Edytuj", + "ui.permission.toolLabel.write": "Zapisz", + "ui.permission.toolLabel.patch": "Łataj", + "ui.permission.toolLabel.globSearch": "Wyszukiwanie Glob", + "ui.permission.toolLabel.grepSearch": "Wyszukiwanie Grep", + "ui.permission.toolLabel.webSearch": "Wyszukiwanie Web", + "ui.permission.toolLabel.list": "Wylistuj", + "ui.permission.toolLabel.externalDirectory": "Czytaj katalog zewnętrzny", + "ui.permission.toolLabel.webFetch": "Pobieranie Web", + "ui.permission.toolLabel.task": "Zadanie", + "ui.permission.toolLabel.skill": "Umiejętność", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Odczyt Todo", + "ui.permission.toolLabel.todoWrite": "Zapis Todo", + "ui.permission.toolLabel.codeSearch": "Wyszukiwanie kodu", "notification.question.title": "Pytanie", "notification.question.description": "{{sessionTitle}} w {{projectName}} ma pytanie", "notification.action.goToSession": "Przejdź do sesji", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 85a4b8bbf28..cb9c589c9f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "Сжать сессию", "command.session.compact.description": "Сократить сессию для уменьшения размера контекста", "command.session.fork": "Создать ответвление", @@ -455,12 +452,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "Конфигурационный файл по адресу {{path}} невалиден: {{message}}", "notification.permission.title": "Требуется разрешение", + "notification.permission.titleSubagent": "Требуется разрешение (субагент)", "notification.permission.description": "{{sessionTitle}} в {{projectName}} требуется разрешение", "ui.permission.manageAutoApprove": "Управление правилами автоодобрения", "ui.permission.rule.addToAllowed": "Добавить в список разрешённых", "ui.permission.rule.removeFromAllowed": "Убрать из списка разрешённых", "ui.permission.rule.addToDenied": "Добавить в список запрещённых", "ui.permission.rule.removeFromDenied": "Убрать из списка запрещённых", + "ui.permission.toolLabel.read": "Чтение", + "ui.permission.toolLabel.edit": "Редактирование", + "ui.permission.toolLabel.write": "Запись", + "ui.permission.toolLabel.patch": "Патч", + "ui.permission.toolLabel.globSearch": "Glob-поиск", + "ui.permission.toolLabel.grepSearch": "Grep-поиск", + "ui.permission.toolLabel.webSearch": "Веб-поиск", + "ui.permission.toolLabel.list": "Список", + "ui.permission.toolLabel.externalDirectory": "Чтение внешнего каталога", + "ui.permission.toolLabel.webFetch": "Веб-загрузка", + "ui.permission.toolLabel.task": "Задача", + "ui.permission.toolLabel.skill": "Навык", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "Чтение списка задач", + "ui.permission.toolLabel.todoWrite": "Запись списка задач", + "ui.permission.toolLabel.codeSearch": "Поиск кода", "notification.question.title": "Вопрос", "notification.question.description": "У {{sessionTitle}} в {{projectName}} есть вопрос", "notification.action.goToSession": "Перейти к сессии", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 92788adbb06..49d1616047a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -83,9 +83,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "บีบอัดเซสชัน", "command.session.compact.description": "สรุปเซสชันเพื่อลดขนาดบริบท", "command.session.fork": "แตกแขนงจากข้อความ", @@ -450,12 +447,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "ไฟล์กำหนดค่าที่ {{path}} ไม่ถูกต้อง: {{message}}", "notification.permission.title": "ต้องการสิทธิ์", + "notification.permission.titleSubagent": "ต้องการสิทธิ์ (ตัวแทนย่อย)", "notification.permission.description": "{{sessionTitle}} ใน {{projectName}} ต้องการสิทธิ์", "ui.permission.manageAutoApprove": "จัดการกฎการอนุมัติอัตโนมัติ", "ui.permission.rule.addToAllowed": "เพิ่มไปยังรายการที่อนุญาต", "ui.permission.rule.removeFromAllowed": "นำออกจากรายการที่อนุญาต", "ui.permission.rule.addToDenied": "เพิ่มไปยังรายการที่ถูกปฏิเสธ", "ui.permission.rule.removeFromDenied": "นำออกจากรายการที่ถูกปฏิเสธ", + "ui.permission.toolLabel.read": "อ่าน", + "ui.permission.toolLabel.edit": "แก้ไข", + "ui.permission.toolLabel.write": "เขียน", + "ui.permission.toolLabel.patch": "แพตช์", + "ui.permission.toolLabel.globSearch": "ค้นหา Glob", + "ui.permission.toolLabel.grepSearch": "ค้นหา Grep", + "ui.permission.toolLabel.webSearch": "ค้นหาเว็บ", + "ui.permission.toolLabel.list": "รายการ", + "ui.permission.toolLabel.externalDirectory": "อ่านไดเรกทอรีภายนอก", + "ui.permission.toolLabel.webFetch": "ดึงข้อมูลเว็บ", + "ui.permission.toolLabel.task": "งาน", + "ui.permission.toolLabel.skill": "ทักษะ", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "อ่านรายการงาน", + "ui.permission.toolLabel.todoWrite": "เขียนรายการงาน", + "ui.permission.toolLabel.codeSearch": "ค้นหาโค้ด", "notification.question.title": "คำถาม", "notification.question.description": "{{sessionTitle}} ใน {{projectName}} มีคำถาม", "notification.action.goToSession": "ไปที่เซสชัน", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 2c7cbbca7d5..239cde11adc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -87,9 +87,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "精简会话", "command.session.compact.description": "总结会话以减少上下文大小", "command.session.fork": "从消息分叉", @@ -446,12 +443,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "配置文件 {{path}} 无效:{{message}}", "notification.permission.title": "需要权限", + "notification.permission.titleSubagent": "需要权限(子代理)", "notification.permission.description": "{{sessionTitle}}({{projectName}})需要权限", "ui.permission.manageAutoApprove": "管理自动审批规则", "ui.permission.rule.addToAllowed": "添加到允许列表", "ui.permission.rule.removeFromAllowed": "从允许列表移除", "ui.permission.rule.addToDenied": "添加到拒绝列表", "ui.permission.rule.removeFromDenied": "从拒绝列表移除", + "ui.permission.toolLabel.read": "读取", + "ui.permission.toolLabel.edit": "编辑", + "ui.permission.toolLabel.write": "写入", + "ui.permission.toolLabel.patch": "补丁", + "ui.permission.toolLabel.globSearch": "Glob搜索", + "ui.permission.toolLabel.grepSearch": "Grep搜索", + "ui.permission.toolLabel.webSearch": "Web搜索", + "ui.permission.toolLabel.list": "列出", + "ui.permission.toolLabel.externalDirectory": "读取外部目录", + "ui.permission.toolLabel.webFetch": "Web获取", + "ui.permission.toolLabel.task": "任务", + "ui.permission.toolLabel.skill": "技能", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "读取待办", + "ui.permission.toolLabel.todoWrite": "更新待办", + "ui.permission.toolLabel.codeSearch": "代码搜索", "notification.question.title": "问题", "notification.question.description": "{{sessionTitle}}({{projectName}})有一个问题", "notification.action.goToSession": "前往会话", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index f36cb677dfe..ac4842b852b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -87,9 +87,6 @@ export const dict = { "revert.banner.redo": "Redo", "revert.banner.redo.all": "Redo All", "revert.banner.hint": "Send a new message to make this permanent", - "revert.turn.revert": "Revert to here", - "revert.turn.copy": "Copy message", - "revert.bottom.undo": "Undo Changes", "command.session.compact": "精簡工作階段", "command.session.compact.description": "總結工作階段以減少上下文大小", "command.session.fork": "從訊息分支", @@ -447,12 +444,30 @@ export const dict = { "error.chain.configInvalidWithMessage": "設定檔 {{path}} 無效:{{message}}", "notification.permission.title": "需要權限", + "notification.permission.titleSubagent": "需要權限(子代理)", "notification.permission.description": "{{sessionTitle}}({{projectName}})需要權限", "ui.permission.manageAutoApprove": "管理自動核准規則", "ui.permission.rule.addToAllowed": "加入允許清單", "ui.permission.rule.removeFromAllowed": "從允許清單移除", "ui.permission.rule.addToDenied": "加入拒絕清單", "ui.permission.rule.removeFromDenied": "從拒絕清單移除", + "ui.permission.toolLabel.read": "讀取", + "ui.permission.toolLabel.edit": "編輯", + "ui.permission.toolLabel.write": "寫入", + "ui.permission.toolLabel.patch": "補丁", + "ui.permission.toolLabel.globSearch": "Glob搜尋", + "ui.permission.toolLabel.grepSearch": "Grep搜尋", + "ui.permission.toolLabel.webSearch": "Web搜尋", + "ui.permission.toolLabel.list": "列出", + "ui.permission.toolLabel.externalDirectory": "讀取外部目錄", + "ui.permission.toolLabel.webFetch": "Web取得", + "ui.permission.toolLabel.task": "任務", + "ui.permission.toolLabel.skill": "技能", + "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.toolLabel.bash": "Bash", + "ui.permission.toolLabel.todoRead": "讀取待辦", + "ui.permission.toolLabel.todoWrite": "更新待辦", + "ui.permission.toolLabel.codeSearch": "程式碼搜尋", "notification.question.title": "問題", "notification.question.description": "{{sessionTitle}}({{projectName}})有一個問題", "notification.action.goToSession": "前往工作階段", diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 8dda627a49c..ff4e78251bd 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -1038,6 +1038,62 @@ text-align: center; } +/* ============================================ + Slash Command Dropdown + ============================================ */ + +.slash-command-dropdown { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + right: 0; + z-index: 10; + background: var(--vscode-editorWidget-background, var(--vscode-input-background)); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border)); + border-radius: 4px; + overflow: hidden; + max-height: 200px; + overflow-y: auto; +} + +.slash-command-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + cursor: pointer; + font-size: 12px; + color: var(--vscode-foreground); + min-width: 0; +} + +.slash-command-item--active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.slash-command-name { + flex-shrink: 0; + font-weight: 500; + font-family: var(--vscode-editor-font-family, monospace); +} + +.slash-command-desc { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.6; + font-size: 11px; +} + +.slash-command-empty { + padding: 8px 10px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + text-align: center; +} + /* ============================================ Model Selector (uses kilo-ui Popover) ============================================ */ @@ -1294,6 +1350,25 @@ font-size: 12px; } + [data-slot="permission-patterns"] { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; + } + + [data-slot="permission-patterns-title"] { + font-size: 12px; + color: var(--text-weak, var(--vscode-descriptionForeground)); + } + + [data-slot="permission-pattern"] { + font-size: 12px; + font-family: var(--vscode-editor-font-family, monospace); + color: var(--text-weak, var(--vscode-descriptionForeground)); + word-break: break-all; + } + [data-slot="permission-rules"] { margin: 0; max-height: 160px; @@ -1373,15 +1448,6 @@ gap: 6px; padding: 3px 0 3px 8px; min-width: 0; - - &[data-decision="approved"] [data-slot="permission-rule"] { - color: var(--text-success, var(--vscode-testing-iconPassed, #5cb85c)); - } - - &[data-decision="denied"] [data-slot="permission-rule"] { - color: var(--text-error, var(--vscode-errorForeground, #f44)); - text-decoration: line-through; - } } [data-slot="permission-rule-type"] { @@ -1706,11 +1772,10 @@ /* ============================================ Todo tool — align checkboxes with icon in header - The trigger has 8px left padding, 16px icon, 8px gap = 32px total offset. ============================================ */ [data-component="todos"] { - padding-left: 32px; + padding: 12px; } /* ============================================ diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index eca2c6259e8..4f786e65dcc 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -201,6 +201,14 @@ export interface SkillInfo { location: string } +// Slash command info from CLI backend +export interface SlashCommandInfo { + name: string + description?: string + source?: "command" | "mcp" | "skill" + hints: string[] +} + // Agent/mode info from CLI backend export interface AgentInfo { name: string @@ -615,6 +623,11 @@ export interface SkillsLoadedMessage { skills: SkillInfo[] } +export interface CommandsLoadedMessage { + type: "commandsLoaded" + commands: SlashCommandInfo[] +} + export interface AutocompleteSettingsLoadedMessage { type: "autocompleteSettingsLoaded" settings: { @@ -1150,6 +1163,7 @@ export type ExtensionMessage = | ProvidersLoadedMessage | AgentsLoadedMessage | SkillsLoadedMessage + | CommandsLoadedMessage | AutocompleteSettingsLoadedMessage | ChatCompletionResultMessage | FileSearchResultMessage @@ -1294,6 +1308,8 @@ export interface ImportAndSendMessage { agent?: string variant?: string files?: FileAttachment[] + command?: string + commandArgs?: string } export interface LoginRequest { @@ -1352,6 +1368,23 @@ export interface RequestSkillsMessage { type: "requestSkills" } +export interface RequestCommandsMessage { + type: "requestCommands" +} + +export interface SendCommandRequest { + type: "sendCommand" + command: string + arguments: string + messageID?: string + sessionID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: FileAttachment[] +} + export interface RemoveSkillMessage { type: "removeSkill" location: string @@ -1756,6 +1789,8 @@ export type WebviewMessage = | CompactRequest | RequestAgentsMessage | RequestSkillsMessage + | RequestCommandsMessage + | SendCommandRequest | RemoveSkillMessage | RemoveModeMessage | RemoveMcpMessage diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 63963707f4e..13cf21dace7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -19,7 +19,8 @@ "db": "bun drizzle-kit" }, "bin": { - "opencode": "./bin/opencode" + "kilo": "./bin/kilo", + "kilocode": "./bin/kilo" }, "randomField": "this-is-a-random-value-12345", "exports": { @@ -36,6 +37,7 @@ "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", "@standard-schema/spec": "1.0.0", "@tsconfig/bun": "catalog:", "@types/babel__core": "7.20.5", @@ -45,8 +47,8 @@ "@types/yargs": "17.0.33", "@types/which": "3.0.4", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -86,7 +88,6 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", - "@morphllm/morphsdk": "0.2.148", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -109,7 +110,7 @@ "clipboardy": "4.0.0", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "fuzzysort": "3.1.0", "glob": "13.0.5", "google-auth-library": "10.5.0", @@ -136,10 +137,11 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5" + "zod-to-json-schema": "3.24.5", + "@morphllm/morphsdk": "0.2.148" }, "overrides": { - "drizzle-orm": "1.0.0-beta.12-a5629fb" + "drizzle-orm": "1.0.0-beta.16-ea816b6" }, "peerDependencies": {} } diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 264ab6af389..4d53b05fc75 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -4,7 +4,7 @@ import { $ } from "bun" import fs from "fs" import path from "path" import { fileURLToPath } from "url" -import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" +import solidPlugin from "@opentui/solid/bun-plugin" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -51,7 +51,7 @@ const migrations = await Promise.all( Number(match[6]), ) : 0 - return { sql, timestamp } + return { sql, timestamp, name } }), ) console.log(`Loaded ${migrations.length} migrations`) @@ -117,6 +117,12 @@ const allTargets: { arch: "x64", avx2: false, }, + // kilocode_change start - added Windows ARM64 target + { + os: "win32", + arch: "arm64", + }, + // kilocode_change end ] const targets = singleFlag @@ -161,7 +167,9 @@ for (const item of targets) { console.log(`building ${name}`) await $`mkdir -p dist/${name}/bin` - const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js")) + const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js") + const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") + const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath) const workerPath = "./src/cli/cmd/tui/worker.ts" // Use platform-specific bunfs root path based on target OS diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 22be5ab8ec3..40b985b31a7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -34,6 +34,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { shouldSummarize as shouldPasteSummary } from "@/kilocode/paste-summary" export type PromptProps = { sessionID?: string @@ -981,15 +982,14 @@ export function Prompt(props: PromptProps) { } catch {} } - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - !sync.data.config.experimental?.disable_paste_summary - ) { + // kilocode_change start + const summary = shouldPasteSummary(pastedContent) + if (summary.summarize && !sync.data.config.experimental?.disable_paste_summary) { event.preventDefault() - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + pasteText(pastedContent, `[Pasted ~${summary.lines} lines]`) return } + // kilocode_change end // Force layout update and render for the pasted content setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 007c5fe5f27..57590c84183 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -130,8 +130,10 @@ export const TuiThreadCommand = cmd({ // kilocode_change end // Resolve relative paths against PWD to preserve behavior when using --cwd flag - const root = process.env.PWD ?? process.cwd() - const cwd = args.project ? path.resolve(root, args.project) : process.cwd() + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const cwd = args.project + ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) + : root const file = await target() try { process.chdir(cwd) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 1e8d09bb0be..d29fe05ee90 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -56,7 +56,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { setStore("active", order[nextIndex]) evt.preventDefault() } - if (evt.name === "space") { + if (evt.name === "space" || evt.name === " ") { if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2ffc1e5e958..797561edff2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1587,15 +1587,25 @@ export namespace Config { return merged })() - global.reset() + // kilocode_change start — skip dispose when caller opts out (e.g. permission-only saves) + await global.reset() + + if (!dispose) return next; + // kilocode_change end + + + void Instance.disposeAll() + .catch(() => undefined) + .finally(() => { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }) + }) - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }) return next } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index e5e2c5f4e4a..58fdfb0c021 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -56,6 +56,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_DISABLE_CHANNEL_DB = truthy("KILO_DISABLE_CHANNEL_DB") function number(key: string) { const value = process.env[key] diff --git a/packages/opencode/src/kilocode/paste-summary.ts b/packages/opencode/src/kilocode/paste-summary.ts new file mode 100644 index 00000000000..7e8ec5173f7 --- /dev/null +++ b/packages/opencode/src/kilocode/paste-summary.ts @@ -0,0 +1,13 @@ +// kilocode_change - new file + +export function count(text: string) { + return (text.match(/\n/g)?.length ?? 0) + 1 +} + +export function shouldSummarize(text: string) { + const lines = count(text) + return { + lines, + summarize: lines >= 5 || text.length > 800, + } +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index bf96d6a3501..52954b3fbdf 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -233,7 +233,7 @@ export namespace PermissionNext { s.approved.push(...newRules) if (newRules.length > 0) { - await Config.updateGlobal({ permission: toConfig(newRules) }) + await Config.updateGlobal({ permission: toConfig(newRules) }, { dispose: false }) } }, ) @@ -315,7 +315,7 @@ export namespace PermissionNext { action: "allow" as const, })) if (alwaysRules.length > 0) { - await Config.updateGlobal({ permission: toConfig(alwaysRules) }) + await Config.updateGlobal({ permission: toConfig(alwaysRules) }, { dispose: false }) } // kilocode_change end return diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f..df44a3a229c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -18,24 +18,61 @@ const disposal = { all: undefined as Promise | undefined, } +function emit(directory: string) { + GlobalBus.emit("event", { + directory, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) +} + +function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + return iife(async () => { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + })) + await context.provide(ctx, async () => { + await input.init?.() + }) + return ctx + }) +} + +function track(directory: string, next: Promise) { + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory) + throw error + }) + cache.set(directory, task) + return task +} + export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - let existing = cache.get(input.directory) + const directory = Filesystem.resolve(input.directory) + let existing = cache.get(directory) if (!existing) { - Log.Default.info("creating instance", { directory: input.directory }) - existing = iife(async () => { - const { project, sandbox } = await Project.fromDirectory(input.directory) - const ctx = { - directory: input.directory, - worktree: sandbox, - project, - } - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) - cache.set(input.directory, existing) + Log.Default.info("creating instance", { directory }) + existing = track( + directory, + boot({ + directory, + init: input.init, + }), + ) } const ctx = await existing return context.provide(ctx, async () => { @@ -66,19 +103,20 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + const directory = Filesystem.resolve(input.directory) + Log.Default.info("reloading instance", { directory }) + await State.dispose(directory) + cache.delete(directory) + const next = track(directory, boot({ ...input, directory })) + emit(directory) + return await next + }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) - GlobalBus.emit("event", { - directory: Instance.directory, - payload: { - type: "server.instance.disposed", - properties: { - directory: Instance.directory, - }, - }, - }) + emit(Instance.directory) }, 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 dea1e41a87e..fbd73c77ccc 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -347,6 +347,21 @@ 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: z.string(), diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index d0cf242a618..3576380745c 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -195,18 +195,11 @@ export namespace Pty { session.bufferCursor += excess }) ptyProcess.onExit(({ exitCode }) => { + if (session.info.status === "exited") return log.info("session exited", { id, exitCode }) session.info.status = "exited" - for (const [key, ws] of session.subscribers.entries()) { - try { - if (ws.data === key) ws.close() - } catch { - // ignore - } - } - session.subscribers.clear() Bus.publish(Event.Exited, { id, exitCode }) - state().delete(id) + remove(id) }) Bus.publish(Event.Created, { info }) return info @@ -228,6 +221,7 @@ export namespace Pty { export async function remove(id: string) { const session = state().get(id) if (!session) return + state().delete(id) log.info("removing session", { id }) try { session.process.kill() @@ -240,7 +234,6 @@ export namespace Pty { } } session.subscribers.clear() - state().delete(id) Bus.publish(Event.Deleted, { id }) } diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 81092284de6..85314df9371 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -6,6 +6,7 @@ import { Project } from "../../project/project" import z from "zod" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { InstanceBootstrap } from "../../project/bootstrap" export const ProjectRoutes = lazy(() => new Hono() @@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() => return c.json(Instance.project) }, ) + .post( + "/git/init", + describeRoute({ + summary: "Initialize git repository", + description: "Create a git repository for the current project and return the refreshed project info.", + operationId: "project.initGit", + responses: { + 200: { + description: "Project information after git initialization", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + }, + }), + async (c) => { + const dir = Instance.directory + const prev = Instance.project + const next = await Project.initGit({ + directory: dir, + project: prev, + }) + if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) + await Instance.reload({ + directory: dir, + worktree: dir, + project: next, + init: InstanceBootstrap, + }) + return c.json(next) + }, + ) .patch( "/:projectID", describeRoute({ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f1bb817b6f4..85b6ea06070 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,9 +45,10 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" -import { CommitMessageRoutes } from "./routes/commit-message" +import { CommitMessageRoutes } from "./routes/commit-message" // kilocode_change import { EnhancePromptRoutes } from "./routes/enhance-prompt" // kilocode_change import { KilocodeRoutes } from "./routes/kilocode" // kilocode_change +import { Filesystem } from "@/util/filesystem" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -214,13 +215,15 @@ export namespace Server { if (c.req.path === "/log") return next() const workspaceID = 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 = (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })() + const directory = Filesystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) return WorkspaceContext.provide({ workspaceID, diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 14ad579359d..a80dec646c5 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,6 +1,5 @@ import { Bus } from "@/bus" import { Config } from "@/config/config" -import { ulid } from "ulid" import { Provider } from "@/provider/provider" import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" @@ -122,20 +121,35 @@ export namespace ShareNext { data: SDK.Model[] } + function key(item: Data) { + switch (item.type) { + case "session": + return "session" + case "message": + return `message/${item.data.id}` + case "part": + return `part/${item.data.messageID}/${item.data.id}` + case "session_diff": + return "session_diff" + case "model": + return "model" + } + } + const queue = new Map }>() async function sync(sessionID: string, data: Data[]) { if (disabled) return const existing = queue.get(sessionID) if (existing) { for (const item of data) { - existing.data.set("id" in item ? (item.id as string) : ulid(), item) + existing.data.set(key(item), item) } return } const dataMap = new Map() for (const item of data) { - dataMap.set("id" in item ? (item.id as string) : ulid(), item) + dataMap.set(key(item), item) } const timeout = setTimeout(async () => { @@ -182,10 +196,14 @@ export namespace ShareNext { const diffs = await Session.diff(sessionID) const messages = await Array.fromAsync(MessageV2.stream(sessionID)) const models = await Promise.all( - messages - .filter((m) => m.info.role === "user") - .map((m) => (m.info as SDK.UserMessage).model) - .map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)), + Array.from( + new Map( + messages + .filter((m) => m.info.role === "user") + .map((m) => (m.info as SDK.UserMessage).model) + .map((m) => [`${m.providerID}/${m.modelID}`, m] as const), + ).values(), + ).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)), ) await sync(sessionID, [ { diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 20c6bbf8a6f..5119f8f5b14 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -12,8 +12,10 @@ 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" -declare const KILO_MIGRATIONS: { sql: string; timestamp: number }[] | undefined +declare const KILO_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined export const NotFoundError = NamedError.create( "NotFoundError", @@ -25,13 +27,22 @@ export const NotFoundError = NamedError.create( const log = Log.create({ service: "db" }) export namespace Database { - export const Path = path.join(Global.Path.data, "kilo.db") + export function file(channel: string) { + if (channel === "latest" || Flag.KILO_DISABLE_CHANNEL_DB) return "kilo.db" + const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-") + return `kilo-${safe}.db` + } + + export const Path = (() => { + return path.join(Global.Path.data, file(Installation.CHANNEL)) + })() + type Schema = typeof schema export type Transaction = SQLiteTransaction<"sync", void, Schema> type Client = SQLiteBunDatabase - type Journal = { sql: string; timestamp: number }[] + type Journal = { sql: string; timestamp: number; name: string }[] const state = { sqlite: undefined as BunDatabase | undefined, @@ -62,6 +73,7 @@ export namespace Database { return { sql: readFileSync(file, "utf-8"), timestamp: time(name), + name, } }) .filter(Boolean) as Journal @@ -70,9 +82,9 @@ export namespace Database { } export const Client = lazy(() => { - log.info("opening database", { path: path.join(Global.Path.data, "kilo.db") }) + log.info("opening database", { path: Path }) - const sqlite = new BunDatabase(path.join(Global.Path.data, "kilo.db"), { create: true }) + const sqlite = new BunDatabase(Path, { create: true }) state.sqlite = sqlite sqlite.run("PRAGMA journal_mode = WAL") @@ -143,7 +155,7 @@ export namespace Database { } catch (err) { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = [] - const result = Client().transaction((tx) => { + const result = (Client().transaction as any)((tx: TxOrDb) => { return ctx.provide({ tx, effects }, () => callback(tx)) }) for (const effect of effects) effect() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index caac22934e1..5b4468e3c25 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -25,6 +25,15 @@ function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } +function detectLineEnding(text: string): "\n" | "\r\n" { + return text.includes("\r\n") ? "\r\n" : "\n" +} + +function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { + if (ending === "\n") return text + return text.replaceAll("\n", "\r\n") +} + export const EditTool = Tool.define("edit", { description: DESCRIPTION, parameters: z.object({ @@ -79,7 +88,12 @@ export const EditTool = Tool.define("edit", { if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) await FileTime.assert(ctx.sessionID, filePath) contentOld = await Filesystem.readText(filePath) - contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) + + const ending = detectLineEnding(contentOld) + const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) + const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) + + contentNew = replace(contentOld, old, next, params.replaceAll) diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index a87aaeb9866..fb1f5ab9e53 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" -import { dirname, join, relative } from "path" +import { dirname, join, relative, resolve as pathResolve } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" @@ -113,16 +113,22 @@ export namespace Filesystem { } } + // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. + export function resolve(p: string): string { + return normalizePath(pathResolve(windowsPath(p))) + } + export function windowsPath(p: string): string { if (process.platform !== "win32") return p return ( p + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) // Git Bash for Windows paths are typically //... - .replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) // Cygwin git paths are typically /cygdrive//... - .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) // WSL paths are typically /mnt//... - .replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) ) } export function overlaps(a: string, b: string) { diff --git a/packages/opencode/src/util/fn.ts b/packages/opencode/src/util/fn.ts index 9efe4622fcd..19c60265bb8 100644 --- a/packages/opencode/src/util/fn.ts +++ b/packages/opencode/src/util/fn.ts @@ -2,7 +2,14 @@ import { z } from "zod" export function fn(schema: T, cb: (input: z.infer) => Result) { const result = (input: z.infer) => { - const parsed = schema.parse(input) + let parsed + try { + parsed = schema.parse(input) + } catch (e) { + console.trace("schema validation failure stack trace:") + throw e + } + return cb(parsed) } result.force = (input: z.infer) => cb(input) diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts index 78e651e8e84..81da2572170 100644 --- a/packages/opencode/src/util/which.ts +++ b/packages/opencode/src/util/which.ts @@ -3,8 +3,8 @@ import whichPkg from "which" export function which(cmd: string, env?: NodeJS.ProcessEnv) { const result = whichPkg.sync(cmd, { nothrow: true, - path: env?.PATH, - pathExt: env?.PATHEXT, + path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path, + pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, }) return typeof result === "string" ? result : null } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c70ec8f213d..3c90a9fb079 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json") await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } +async function check(map: (dir: string) => string) { + if (process.platform !== "win32") return + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + Config.global.reset() + try { + await writeConfig(globalTmp.path, { + $schema: "https://opencode.ai/config.json", + snapshot: false, + }) + await Instance.provide({ + directory: map(tmp.path), + fn: async () => { + const cfg = await Config.get() + expect(cfg.snapshot).toBe(true) + expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) + expect(Instance.project.id).not.toBe("global") + }, + }) + } finally { + await Instance.disposeAll() + ;(Global.Path as { config: string }).config = prev + Config.global.reset() + } +} + test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -56,6 +84,23 @@ test("loads JSON config file", async () => { }) }) +test("loads project config from Git Bash and MSYS2 paths on Windows", async () => { + // Git Bash and MSYS2 both use //... paths on Windows. + await check((dir) => { + const drive = dir[0].toLowerCase() + const rest = dir.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` + }) +}) + +test("loads project config from Cygwin paths on Windows", async () => { + await check((dir) => { + const drive = dir[0].toLowerCase() + const rest = dir.slice(2).replaceAll("\\", "/") + return `/cygdrive/${drive}${rest}` + }) +}) + test("ignores legacy tui keys in opencode config", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/kilocode/paste-summary.test.ts b/packages/opencode/test/kilocode/paste-summary.test.ts new file mode 100644 index 00000000000..8cd0d18ca84 --- /dev/null +++ b/packages/opencode/test/kilocode/paste-summary.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test" +import { shouldSummarize } from "../../src/kilocode/paste-summary" + +describe("paste-summary", () => { + it("does not summarize 4 lines", () => { + const text = Array.from({ length: 4 }, (_, i) => `line ${i + 1}`).join("\n") + expect(shouldSummarize(text)).toEqual({ lines: 4, summarize: false }) + }) + + it("summarizes 5 lines", () => { + const text = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join("\n") + expect(shouldSummarize(text)).toEqual({ lines: 5, summarize: true }) + }) + + it("does not summarize 800 chars", () => { + const text = "a".repeat(800) + expect(shouldSummarize(text)).toEqual({ lines: 1, summarize: false }) + }) + + it("summarizes 801 chars", () => { + const text = "a".repeat(801) + expect(shouldSummarize(text)).toEqual({ lines: 1, summarize: true }) + }) + + it("summarizes when either threshold triggers independently", () => { + const lines = Array.from({ length: 5 }, () => "a").join("\n") + const chars = "b".repeat(801) + + expect(shouldSummarize(lines)).toEqual({ lines: 5, summarize: true }) + expect(shouldSummarize(chars)).toEqual({ lines: 1, summarize: true }) + }) + + it("keeps 4 lines and 780 chars expanded", () => { + const row = "a".repeat(194) + const text = Array.from({ length: 4 }, () => row).join("\n") + + expect(text.length).toBe(779) + expect(shouldSummarize(text)).toEqual({ lines: 4, summarize: false }) + }) + + it("summarizes 5 lines and 500 chars", () => { + const row = "b".repeat(96) + const text = Array.from({ length: 5 }, () => row).join("\n") + + expect(text.length).toBe(484) + expect(shouldSummarize(text)).toEqual({ lines: 5, summarize: true }) + }) +}) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 5ae45d30a57..0d2bd91aac2 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -50,7 +50,7 @@ const cacheDir = path.join(dir, "cache", "opencode") await fs.mkdir(cacheDir, { recursive: true }) await fs.writeFile(path.join(cacheDir, "version"), "14") -// Clear provider env vars to ensure clean test state +// Clear provider and server auth env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] delete process.env["GOOGLE_API_KEY"] @@ -70,6 +70,8 @@ delete process.env["DEEPSEEK_API_KEY"] delete process.env["FIREWORKS_API_KEY"] delete process.env["CEREBRAS_API_KEY"] delete process.env["SAMBANOVA_API_KEY"] +delete process.env["OPENCODE_SERVER_PASSWORD"] +delete process.env["OPENCODE_SERVER_USERNAME"] // Now safe to import from src/ const { Log } = await import("../src/util/log") diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts new file mode 100644 index 00000000000..49b2c3ec26b --- /dev/null +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { Pty } from "../../src/pty" +import { tmpdir } from "../fixture/fixture" +import { setTimeout as sleep } from "node:timers/promises" + +const wait = async (fn: () => boolean, ms = 2000) => { + const end = Date.now() + ms + while (Date.now() < end) { + if (fn()) return + await sleep(25) + } + throw new Error("timeout waiting for pty events") +} + +const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => { + return log.filter((evt) => evt.id === id).map((evt) => evt.type) +} + +describe("pty", () => { + test("publishes created, exited, deleted in order for /bin/ls + remove", async () => { + if (process.platform === "win32") return + + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = [] + const off = [ + Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), + Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), + Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), + ] + + let id = "" + try { + const info = await Pty.create({ command: "/bin/ls", title: "ls" }) + id = info.id + + await wait(() => pick(log, id).includes("exited")) + + await Pty.remove(id) + await wait(() => pick(log, id).length >= 3) + expect(pick(log, id)).toEqual(["created", "exited", "deleted"]) + } finally { + off.forEach((x) => x()) + if (id) await Pty.remove(id) + } + }, + }) + }) + + test("publishes created, exited, deleted in order for /bin/sh + remove", async () => { + if (process.platform === "win32") return + + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = [] + const off = [ + Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), + Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), + Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), + ] + + let id = "" + try { + const info = await Pty.create({ command: "/bin/sh", title: "sh" }) + id = info.id + + await sleep(100) + + await Pty.remove(id) + await wait(() => pick(log, id).length >= 3) + expect(pick(log, id)).toEqual(["created", "exited", "deleted"]) + } finally { + off.forEach((x) => x()) + if (id) await Pty.remove(id) + } + }, + }) + }) +}) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts new file mode 100644 index 00000000000..50cda0ab586 --- /dev/null +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import path from "path" +import { GlobalBus } from "../../src/bus/global" +import { Snapshot } from "../../src/snapshot" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Filesystem } from "../../src/util/filesystem" +import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await resetDatabase() +}) + +describe("project.initGit endpoint", () => { + test("initializes git and reloads immediately", async () => { + await using tmp = await tmpdir() + const app = Server.App() + const seen: { directory?: string; payload: { type: string } }[] = [] + const fn = (evt: { directory?: string; payload: { type: string } }) => { + seen.push(evt) + } + const reload = Instance.reload + const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) + GlobalBus.on("event", fn) + + try { + const init = await app.request("/project/git/init", { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + }, + }) + const body = await init.json() + expect(init.status).toBe(200) + expect(body).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.path, + }) + expect(reloadSpy).toHaveBeenCalledTimes(1) + expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap) + expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe( + true, + ) + expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false) + + const current = await app.request("/project/current", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + expect(current.status).toBe(200) + expect(await current.json()).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.path, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Snapshot.track()).toBeTruthy() + }, + }) + } finally { + reloadSpy.mockRestore() + GlobalBus.off("event", fn) + } + }) + + test("does not reload when the project is already git", async () => { + await using tmp = await tmpdir({ git: true }) + const app = Server.App() + const seen: { directory?: string; payload: { type: string } }[] = [] + const fn = (evt: { directory?: string; payload: { type: string } }) => { + seen.push(evt) + } + const reload = Instance.reload + const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) + GlobalBus.on("event", fn) + + try { + const init = await app.request("/project/git/init", { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + }, + }) + expect(init.status).toBe(200) + expect(await init.json()).toMatchObject({ + vcs: "git", + worktree: tmp.path, + }) + expect( + seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length, + ).toBe(0) + expect(reloadSpy).toHaveBeenCalledTimes(0) + + const current = await app.request("/project/current", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + expect(current.status).toBe(200) + expect(await current.json()).toMatchObject({ + vcs: "git", + worktree: tmp.path, + }) + } finally { + reloadSpy.mockRestore() + GlobalBus.off("event", fn) + } + }) +}) diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts new file mode 100644 index 00000000000..ea065dd92ad --- /dev/null +++ b/packages/opencode/test/storage/db.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "../../src/storage/db" + +describe("Database.file", () => { + test("uses the shared database for latest", () => { + expect(Database.file("latest")).toBe("kilo.db") // kilocode_change + }) + + test("sanitizes preview channels for filenames", () => { + expect(Database.file("fix/windows-modified-files-tracking")).toBe("kilo-fix-windows-modified-files-tracking.db") // kilocode_change + }) +}) diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index b70c9e1ebe0..40dd6114538 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -84,6 +84,7 @@ function createTestDb() { .map((entry) => ({ sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"), timestamp: Number(entry.name.split("_")[0]), + name: entry.name, })) .sort((a, b) => a.timestamp - b.timestamp) migrate(drizzle({ client: sqlite }), migrations) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index c3cf0404b9a..bcf75da05cd 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -451,6 +451,189 @@ describe("tool.edit", () => { }) }) + describe("line endings", () => { + const old = "alpha\nbeta\ngamma" + const next = "alpha\nbeta-updated\ngamma" + const alt = "alpha\nbeta\nomega" + + const normalize = (text: string, ending: "\n" | "\r\n") => { + const normalized = text.replaceAll("\r\n", "\n") + if (ending === "\n") return normalized + return normalized.replaceAll("\n", "\r\n") + } + + const count = (content: string) => { + const crlf = content.match(/\r\n/g)?.length ?? 0 + const lf = content.match(/\n/g)?.length ?? 0 + return { + crlf, + lf: lf - crlf, + } + } + + const expectLf = (content: string) => { + const counts = count(content) + expect(counts.crlf).toBe(0) + expect(counts.lf).toBeGreaterThan(0) + } + + const expectCrlf = (content: string) => { + const counts = count(content) + expect(counts.lf).toBe(0) + expect(counts.crlf).toBeGreaterThan(0) + } + + type Input = { + content: string + oldString: string + newString: string + replaceAll?: boolean + } + + const apply = async (input: Input) => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), input.content) + }, + }) + + return await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + const filePath = path.join(tmp.path, "test.txt") + FileTime.read(ctx.sessionID, filePath) + await edit.execute( + { + filePath, + oldString: input.oldString, + newString: input.newString, + replaceAll: input.replaceAll, + }, + ctx, + ) + return await Bun.file(filePath).text() + }, + }) + } + + test("preserves LF with LF multi-line strings", async () => { + const content = normalize(old + "\n", "\n") + const output = await apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }) + + test("preserves CRLF with CRLF multi-line strings", async () => { + const content = normalize(old + "\n", "\r\n") + const output = await apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }) + + test("preserves LF when old/new use CRLF", async () => { + const content = normalize(old + "\n", "\n") + const output = await apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }) + + test("preserves CRLF when old/new use LF", async () => { + const content = normalize(old + "\n", "\r\n") + const output = await apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }) + + test("preserves LF when newString uses CRLF", async () => { + const content = normalize(old + "\n", "\n") + const output = await apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }) + + test("preserves CRLF when newString uses LF", async () => { + const content = normalize(old + "\n", "\r\n") + const output = await apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }) + + test("preserves LF with mixed old/new line endings", async () => { + const content = normalize(old + "\n", "\n") + const output = await apply({ + content, + oldString: "alpha\nbeta\r\ngamma", + newString: "alpha\r\nbeta\nomega", + }) + expect(output).toBe(normalize(alt + "\n", "\n")) + expectLf(output) + }) + + test("preserves CRLF with mixed old/new line endings", async () => { + const content = normalize(old + "\n", "\r\n") + const output = await apply({ + content, + oldString: "alpha\r\nbeta\ngamma", + newString: "alpha\nbeta\r\nomega", + }) + expect(output).toBe(normalize(alt + "\n", "\r\n")) + expectCrlf(output) + }) + + test("replaceAll preserves LF for multi-line blocks", async () => { + const blockOld = "alpha\nbeta" + const blockNew = "alpha\nbeta-updated" + const content = normalize(blockOld + "\n" + blockOld + "\n", "\n") + const output = await apply({ + content, + oldString: normalize(blockOld, "\n"), + newString: normalize(blockNew, "\n"), + replaceAll: true, + }) + expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n")) + expectLf(output) + }) + + test("replaceAll preserves CRLF for multi-line blocks", async () => { + const blockOld = "alpha\nbeta" + const blockNew = "alpha\nbeta-updated" + const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n") + const output = await apply({ + content, + oldString: normalize(blockOld, "\r\n"), + newString: normalize(blockNew, "\r\n"), + replaceAll: true, + }) + expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n")) + expectCrlf(output) + }) + }) + describe("concurrent editing", () => { test("serializes concurrent edits to same file", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index a6255db88f5..c757e3424db 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -440,4 +440,67 @@ describe("filesystem", () => { expect(await fs.readFile(filepath, "utf-8")).toBe(content) }) }) + + describe("resolve()", () => { + test("resolves slash-prefixed drive paths on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const forward = tmp.path.replaceAll("\\", "/") + expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path)) + }) + + test("resolves slash-prefixed drive roots on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toUpperCase() + expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`)) + }) + + test("resolves Git Bash and MSYS2 paths on Windows", async () => { + // Git Bash and MSYS2 both use //... paths on Windows. + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toLowerCase() + const rest = tmp.path.slice(2).replaceAll("\\", "/") + expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) + }) + + test("resolves Git Bash and MSYS2 drive roots on Windows", async () => { + // Git Bash and MSYS2 both use / paths on Windows. + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toLowerCase() + expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) + }) + + test("resolves Cygwin paths on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toLowerCase() + const rest = tmp.path.slice(2).replaceAll("\\", "/") + expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) + }) + + test("resolves Cygwin drive roots on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toLowerCase() + expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) + }) + + test("resolves WSL mount paths on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toLowerCase() + const rest = tmp.path.slice(2).replaceAll("\\", "/") + expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) + }) + + test("resolves WSL mount roots on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + const drive = tmp.path[0].toLowerCase() + expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) + }) + }) }) diff --git a/packages/opencode/test/util/which.test.ts b/packages/opencode/test/util/which.test.ts index 323173b1812..70c2fb2d9fc 100644 --- a/packages/opencode/test/util/which.test.ts +++ b/packages/opencode/test/util/which.test.ts @@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv { } } +function envPath(Path: string): NodeJS.ProcessEnv { + return { + Path, + PathExt: process.env["PathExt"] ?? process.env["PATHEXT"], + } +} + function same(a: string | null, b: string) { if (process.platform === "win32") { expect(a?.toLowerCase()).toBe(b.toLowerCase()) @@ -79,4 +86,15 @@ describe("util.which", () => { expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file) }) + + test("uses Windows Path casing fallback", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "bin") + await fs.mkdir(bin) + const file = await cmd(bin, "mixed") + + same(which("mixed", envPath(bin)), file) + }) }) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 499834e72d7..92cf3cc13b5 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -4,11 +4,6 @@ "version": "7.0.50", "type": "module", "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/Kilo-Org/kilocode", - "directory": "packages/sdk/js" - }, "scripts": { "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts" @@ -33,5 +28,10 @@ "@typescript/native-preview": "catalog:" }, "dependencies": {}, - "peerDependencies": {} + "peerDependencies": {}, + "repository": { + "type": "git", + "url": "https://github.com/Kilo-Org/kilocode", + "directory": "packages/sdk/js" + } } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 82ef771ef72..6eeaa122618 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -101,6 +101,7 @@ import type { PermissionSaveAlwaysRulesErrors, PermissionSaveAlwaysRulesResponses, ProjectCurrentResponses, + ProjectInitGitResponses, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, @@ -457,6 +458,36 @@ export class Project extends HeyApiClient { }) } + /** + * Initialize git repository + * + * Create a git repository for the current project and return the refreshed project info. + */ + public initGit( + 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).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + /** * Update project * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index bce8bbdea7b..0d1af70d138 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2143,6 +2143,25 @@ export type ProjectCurrentResponses = { export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] +export type ProjectInitGitData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/git/init" +} + +export type ProjectInitGitResponses = { + /** + * Project information after git initialization + */ + 200: Project +} + +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] + export type ProjectUpdateData = { body?: { name?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5181da5b925..ef3b02a243d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -340,6 +340,47 @@ ] } }, + "/project/git/init": { + "post": { + "operationId": "project.initGit", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Initialize git repository", + "description": "Create a git repository for the current project and return the refreshed project info.", + "responses": { + "200": { + "description": "Project information after git initialization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" + } + ] + } + }, "/project/{projectID}": { "patch": { "operationId": "project.update", diff --git a/packages/storybook/.storybook/mocks/solid-router.tsx b/packages/storybook/.storybook/mocks/solid-router.tsx index 872c17ffe45..c1bda30f4bd 100644 --- a/packages/storybook/.storybook/mocks/solid-router.tsx +++ b/packages/storybook/.storybook/mocks/solid-router.tsx @@ -11,6 +11,14 @@ export function useNavigate() { return () => undefined } +export function useLocation() { + return { + pathname: "/story/session/story-session", + search: "", + hash: "", + } +} + export function MemoryRouter(props: ParentProps) { return props.children } diff --git a/packages/ui/package.json b/packages/ui/package.json index 2e5c529f82a..5ffafddbbc7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,6 +6,8 @@ "exports": { "./package.json": "./package.json", "./*": "./src/components/*.tsx", + "./tool-utils": "./src/components/tool-utils.ts", + "./text-utils": "./src/components/text-utils.ts", "./i18n/*": "./src/i18n/*.ts", "./pierre": "./src/pierre/index.ts", "./pierre/*": "./src/pierre/*.ts", diff --git a/packages/ui/script/colors.txt b/packages/ui/script/colors.txt index f8496685360..2581b0e3e3f 100644 --- a/packages/ui/script/colors.txt +++ b/packages/ui/script/colors.txt @@ -114,6 +114,7 @@ --border-weak-selected: var(--cobalt-light-alpha-5); --border-weak-disabled: var(--smoke-light-alpha-6); --border-weak-focus: var(--smoke-light-alpha-7); +--border-weaker-base: var(--smoke-light-alpha-3); --border-interactive-base: var(--cobalt-light-7); --border-interactive-hover: var(--cobalt-light-8); --border-interactive-active: var(--cobalt-light-9); @@ -224,11 +225,5 @@ --markdown-image-text: #318795; --markdown-code-block: #1A1A1A; --border-color: #FFFFFF; ---border-weaker-base: var(--smoke-light-alpha-3); ---border-weaker-hover: var(--smoke-light-alpha-4); ---border-weaker-active: var(--smoke-light-alpha-6); ---border-weaker-selected: var(--cobalt-light-alpha-4); ---border-weaker-disabled: var(--smoke-light-alpha-2); ---border-weaker-focus: var(--smoke-light-alpha-6); --button-ghost-hover: var(--smoke-light-alpha-2); --button-ghost-hover2: var(--smoke-light-alpha-3); diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index 022b347e968..b69ce65084b 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -9,19 +9,20 @@ display: inline-flex; flex-direction: row-reverse; align-items: baseline; - justify-content: flex-end; + justify-content: flex-start; line-height: inherit; width: var(--animated-number-width, 1ch); - overflow: hidden; - transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + overflow: clip; + transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="animated-number-digit"] { display: inline-block; + flex-shrink: 0; width: 1ch; height: 1em; line-height: 1em; - overflow: hidden; + overflow: clip; vertical-align: baseline; -webkit-mask-image: linear-gradient( to bottom, @@ -46,7 +47,7 @@ flex-direction: column; transform: translateY(calc(var(--animated-number-offset, 10) * -1em)); transition-property: transform; - transition-duration: var(--animated-number-duration, 560ms); + transition-duration: var(--animated-number-duration, 600ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index b5fceba2563..dfe368b8bbf 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 600 +const DURATION = 800 function normalize(value: number) { return ((value % 10) + 10) % 10 @@ -90,10 +90,35 @@ export function AnimatedNumber(props: { value: number; class?: string }) { ) const width = createMemo(() => `${digits().length}ch`) + const [exitingDigits, setExitingDigits] = createSignal([]) + let exitTimer: number | undefined + + createEffect( + on( + digits, + (current, prev) => { + if (prev && current.length < prev.length) { + setExitingDigits(prev.slice(current.length)) + clearTimeout(exitTimer) + exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION) + } else { + clearTimeout(exitTimer) + setExitingDigits([]) + } + }, + { defer: true }, + ), + ) + + const displayDigits = createMemo(() => { + const exiting = exitingDigits() + return exiting.length ? [...digits(), ...exiting] : digits() + }) + return ( - {(digit) => } + {(digit) => } ) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 8ef997bad74..e0b935200a5 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -8,54 +8,28 @@ justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { - width: auto; + width: 100%; + min-width: 0; display: flex; align-items: center; align-self: stretch; gap: 8px; } - [data-slot="basic-tool-tool-indicator"] { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - [data-component="spinner"] { - width: 16px; - height: 16px; - } - } - - [data-slot="basic-tool-tool-spinner"] { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--text-weak); - - [data-component="spinner"] { - width: 16px; - height: 16px; - } - } - [data-slot="icon-svg"] { flex-shrink: 0; } [data-slot="basic-tool-tool-info"] { - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { width: auto; + max-width: 100%; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -63,11 +37,12 @@ } [data-slot="basic-tool-tool-info-main"] { + flex: 0 1 auto; display: flex; - align-items: baseline; + align-items: center; gap: 8px; min-width: 0; - overflow: hidden; + overflow: clip; } [data-slot="basic-tool-tool-title"] { @@ -79,22 +54,14 @@ line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - - &.capitalize { - text-transform: capitalize; - } - - &.agent-title { - color: var(--text-strong); - font-weight: var(--font-weight-medium); - } } [data-slot="basic-tool-tool-subtitle"] { - flex-shrink: 1; + display: inline-block; + flex: 0 1 auto; + max-width: 100%; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; font-family: var(--font-family-sans); font-size: 14px; @@ -139,8 +106,7 @@ [data-slot="basic-tool-tool-arg"] { flex-shrink: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; font-family: var(--font-family-sans); font-size: 14px; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index f78b619cce8..f086e91fd02 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,8 +1,21 @@ -import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" +import { + createEffect, + createSignal, + For, + Match, + on, + onCleanup, + onMount, + Show, + splitProps, + Switch, + type JSX, +} from "solid-js" +import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion" import { Collapsible } from "./collapsible" import { Icon, type IconProps } from "./icon" // kilocode_change: added Icon import { TextShimmer } from "./text-shimmer" +import { hold } from "./tool-utils" export type TriggerTitle = { title: string @@ -20,26 +33,107 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { ) } -export interface BasicToolProps { - icon: IconProps["name"] +interface ToolCallPanelBaseProps { + icon: string trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string + animate?: boolean hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean defer?: boolean locked?: boolean - animated?: boolean + watchDetails?: boolean + springContent?: boolean onSubtitleClick?: () => void } -const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } +function ToolCallTriggerBody(props: { + trigger: TriggerTitle | JSX.Element + pending: boolean + onSubtitleClick?: () => void + arrow?: boolean + icon?: string +}) { + const iconName = props.icon as unknown as IconProps["name"] // kilocode_change: added type assertion for icon name -export function BasicTool(props: BasicToolProps) { + return ( +
+
+ {/* kilocode_change start */} + + + + {/* kilocode_change end */} +
+ + + {(trigger) => ( +
+
+ + + + + + { + if (!props.onSubtitleClick) return + e.stopPropagation() + props.onSubtitleClick() + }} + > + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + + +
+ {trigger().action} +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+ ) +} + +function ToolCallPanel(props: ToolCallPanelBaseProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) - const pending = () => props.status === "pending" || props.status === "running" + const pendingRaw = () => props.status === "pending" || props.status === "running" + const pending = hold(pendingRaw, 1000) + const watchDetails = () => props.watchDetails !== false let frame: number | undefined @@ -59,7 +153,7 @@ export function BasicTool(props: BasicToolProps) { on( open, (value) => { - if (!props.defer) return + if (!props.defer || props.springContent) return if (!value) { cancel() setReady(false) @@ -77,36 +171,110 @@ export function BasicTool(props: BasicToolProps) { ), ) - // Animated height for collapsible open/close + // Animated content height — single springValue drives all height changes let contentRef: HTMLDivElement | undefined - let heightAnim: AnimationPlaybackControls | undefined + let bodyRef: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let observer: ResizeObserver | undefined + let resizeFrame: number | undefined const initialOpen = open() + const heightSpring = tunableSpringValue(0, COLLAPSIBLE_SPRING) + + const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) + + const doOpen = () => { + if (!contentRef || !bodyRef) return + contentRef.style.display = "" + // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles) + if (bodyRef.style.opacity === "") { + bodyRef.style.opacity = "0" + bodyRef.style.filter = "blur(2px)" + } + const next = read() + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING) + fadeAnim.finished.then(() => { + if (!bodyRef) return + bodyRef.style.opacity = "" + bodyRef.style.filter = "" + }) + heightSpring.set(next) + } + + const doClose = () => { + if (!contentRef || !bodyRef) return + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING) + fadeAnim.finished.then(() => { + if (!contentRef || open()) return + contentRef.style.display = "none" + }) + heightSpring.set(0) + } + + const grow = () => { + if (!contentRef || !open()) return + const next = read() + if (Math.abs(next - heightSpring.get()) < 1) return + heightSpring.set(next) + } + + onMount(() => { + if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return + + const offChange = heightSpring.on("change", (v) => { + if (!contentRef) return + contentRef.style.height = `${Math.max(0, Math.ceil(v))}px` + }) + onCleanup(() => { + offChange() + }) + + if (watchDetails()) { + observer = new ResizeObserver(() => { + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + grow() + }) + }) + observer.observe(bodyRef) + } + + if (!open()) return + if (contentRef.style.display !== "none") { + const next = read() + heightSpring.jump(next) + contentRef.style.height = `${next}px` + return + } + let mountFrame: number | undefined = requestAnimationFrame(() => { + mountFrame = undefined + if (!open()) return + doOpen() + }) + onCleanup(() => { + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + }) + }) createEffect( on( open, (isOpen) => { - if (!props.animated || !contentRef) return - heightAnim?.stop() - if (isOpen) { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "auto" }, SPRING) - heightAnim.finished.then(() => { - if (!contentRef || !open()) return - contentRef.style.overflow = "visible" - contentRef.style.height = "auto" - }) - } else { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "0px" }, SPRING) - } + if (!props.springContent || props.animate === false || !contentRef) return + if (isOpen) doOpen() + else doClose() }, { defer: true }, ), ) onCleanup(() => { - heightAnim?.stop() + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + fadeAnim?.stop() + heightSpring.destroy() }) const handleOpenChange = (value: boolean) => { @@ -118,90 +286,35 @@ export function BasicTool(props: BasicToolProps) { return ( -
-
- {/* kilocode_change start */} - - - - {/* kilocode_change end */} -
- - - {(trigger) => ( -
-
- - - - - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
+
- +
- {props.children} +
+ {props.children} +
- + - {props.children} + +
{props.children}
+
@@ -227,6 +340,62 @@ function args(input: Record | undefined) { .slice(0, 3) } +export interface ToolCallRowProps { + variant: "row" + icon: string + trigger: TriggerTitle | JSX.Element + status?: string + animate?: boolean + onSubtitleClick?: () => void + open?: boolean + showArrow?: boolean + onOpenChange?: (value: boolean) => void +} +export interface ToolCallPanelProps extends Omit { + variant: "panel" +} +export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps +function ToolCallRoot(props: ToolCallProps) { + const pending = () => props.status === "pending" || props.status === "running" + if (props.variant === "row") { + return ( + +
+ +
+
+ } + > + {(onOpenChange) => ( + + + + + + )} +
+ ) + } + + const [, rest] = splitProps(props, ["variant"]) + return +} +export const ToolCall = ToolCallRoot + export function GenericTool(props: { tool: string status?: string @@ -234,7 +403,8 @@ export function GenericTool(props: { input?: Record }) { return ( - ) } diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index bab2c4f9269..1a86338bdc8 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -8,14 +8,18 @@ border-radius: var(--radius-md); overflow: visible; - &.tool-collapsible { - gap: 8px; + &.tool-collapsible [data-slot="collapsible-trigger"] { + height: 37px; + } + + &.tool-collapsible [data-slot="basic-tool-content-inner"] { + padding-top: 0; } [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 32px; + height: 36px; padding: 0; align-items: center; align-self: stretch; @@ -23,6 +27,17 @@ user-select: none; color: var(--text-base); + > [data-component="tool-trigger"][data-arrow] { + width: auto; + max-width: 100%; + flex: 0 1 auto; + + [data-slot="basic-tool-tool-trigger-content"] { + width: auto; + max-width: 100%; + } + } + [data-slot="collapsible-arrow"] { opacity: 0; transition: opacity 0.15s ease; @@ -50,9 +65,6 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - /* &:hover { */ - /* background-color: var(--surface-base); */ - /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -82,16 +94,16 @@ } [data-slot="collapsible-content"] { - overflow: hidden; - /* animation: slideUp 250ms ease-out; */ + overflow: clip; &[data-expanded] { overflow: visible; } - /* &[data-expanded] { */ - /* animation: slideDown 250ms ease-out; */ - /* } */ + /* JS-animated content: overflow managed by animate() */ + &[data-spring-content] { + overflow: clip; + } } &[data-variant="ghost"] { @@ -103,9 +115,6 @@ border: none; padding: 0; - /* &:hover { */ - /* color: var(--text-strong); */ - /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -122,21 +131,3 @@ } } } - -@keyframes slideDown { - from { - height: 0; - } - to { - height: var(--kb-collapsible-content-height); - } -} - -@keyframes slideUp { - from { - height: var(--kb-collapsible-content-height); - } - to { - height: 0; - } -} diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx new file mode 100644 index 00000000000..6a6ea498f0e --- /dev/null +++ b/packages/ui/src/components/context-tool-results.tsx @@ -0,0 +1,199 @@ +import { createMemo, createSignal, For, onMount } from "solid-js" +import type { ToolPart } from "@kilocode/sdk/v2" +import { getFilename } from "@opencode-ai/util/path" +import { useI18n } from "../context/i18n" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import { ToolCall } from "./basic-tool" +import { ToolStatusTitle } from "./tool-status-title" +import { AnimatedCountList } from "./tool-count-summary" +import { RollingResults } from "./rolling-results" +import { GROW_SPRING } from "./motion" +import { useSpring } from "./motion-spring" +import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils" + +function contextToolLabel(part: ToolPart): { action: string; detail: string } { + const state = part.state + const title = "title" in state ? (state.title as string | undefined) : undefined + const input = state.input + if (part.tool === "read") { + const path = input?.filePath as string | undefined + return { action: "Read", detail: title || (path ? getFilename(path) : "") } + } + if (part.tool === "grep") { + const pattern = input?.pattern as string | undefined + return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") } + } + if (part.tool === "glob") { + const pattern = input?.pattern as string | undefined + return { action: "Find", detail: title || (pattern ?? "") } + } + if (part.tool === "list") { + const path = input?.path as string | undefined + return { action: "List", detail: title || (path ? getFilename(path) : "") } + } + return { action: part.tool, detail: title || "" } +} + +function contextToolSummary(parts: ToolPart[]) { + let read = 0 + let search = 0 + let list = 0 + for (const part of parts) { + if (part.tool === "read") read++ + else if (part.tool === "glob" || part.tool === "grep") search++ + else if (part.tool === "list") list++ + } + return { read, search, list } +} + +export function ContextToolGroupHeader(props: { + parts: ToolPart[] + pending: boolean + open: boolean + onOpenChange: (value: boolean) => void +}) { + const i18n = useI18n() + const summary = createMemo(() => contextToolSummary(props.parts)) + return ( + { + if (!props.pending) props.onOpenChange(v) + }} + trigger={ +
+ + + + + + + + +
+ } + /> + ) +} + +export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) { + let contentRef: HTMLDivElement | undefined + let bodyRef: HTMLDivElement | undefined + let scrollRef: HTMLDivElement | undefined + const updateMask = () => { + if (scrollRef) updateScrollMask(scrollRef) + } + + useCollapsible({ + content: () => contentRef, + body: () => bodyRef, + open: () => props.expanded, + onOpen: updateMask, + }) + + return ( +
+
+
+ + {(part) => { + const label = createMemo(() => contextToolLabel(part)) + return ( +
+ {label().action} + {label().detail} +
+ ) + }} +
+
+
+
+ ) +} + +export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { + const wiped = new Set() + const [mounted, setMounted] = createSignal(false) + onMount(() => setMounted(true)) + const reduce = prefersReducedMotion + const show = () => mounted() && props.pending + const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) + const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) + return ( +
+ part.callID || part.id} + render={(part) => { + const label = createMemo(() => contextToolLabel(part)) + const k = part.callID || part.id + return ( +
+ {label().action} + {(() => { + const [detailRef, setDetailRef] = createSignal() + useRowWipe({ + id: () => k, + text: () => label().detail, + ref: detailRef, + seen: wiped, + }) + return ( + + {label().detail} + + ) + })()} +
+ ) + }} + /> +
+ ) +} diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx new file mode 100644 index 00000000000..ec4921ab3a2 --- /dev/null +++ b/packages/ui/src/components/grow-box.tsx @@ -0,0 +1,426 @@ +import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" +import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" + +export interface GrowBoxProps { + children: JSX.Element + /** Enable animation. When false, content shows immediately at full height. */ + animate?: boolean + /** Animate height from 0 to content height. Default: true. */ + grow?: boolean + /** Keep watching body size and animate subsequent height changes. Default: false. */ + watch?: boolean + /** Fade in body content (opacity + blur). Default: true. */ + fade?: boolean + /** Top padding in px on the body wrapper. Default: 0. */ + gap?: number + /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */ + autoHeight?: boolean + /** Controlled visibility for animating open/close without unmounting children. */ + open?: boolean + /** Animate controlled open/close changes after mount. Default: true. */ + animateToggle?: boolean + /** data-slot attribute on the root div. */ + slot?: string + /** CSS class on the root div. */ + class?: string + /** Override mount and resize spring config. Default: GROW_SPRING. */ + spring?: SpringConfig + /** Override controlled open/close spring config. Default: spring. */ + toggleSpring?: SpringConfig + /** Show a temporary bottom edge fade while height animation is running. */ + edge?: boolean + /** Edge fade height in px. Default: 20. */ + edgeHeight?: number + /** Edge fade opacity (0-1). Default: 1. */ + edgeOpacity?: number + /** Delay before edge fades out after height settles. Default: 320. */ + edgeIdle?: number + /** Edge fade-out duration in seconds. Default: 0.24. */ + edgeFade?: number + /** Edge fade-in duration in seconds. Default: 0.2. */ + edgeRise?: number +} + +/** + * Wraps children in a container that animates from zero height on mount. + * + * Includes a ResizeObserver so content changes after mount are also spring-animated. + * Used for timeline turns, assistant part groups, and user messages. + */ +export function GrowBox(props: GrowBoxProps) { + const reduce = prefersReducedMotion + const spring = () => props.spring ?? GROW_SPRING + const toggleSpring = () => props.toggleSpring ?? spring() + let mode: "mount" | "toggle" = "mount" + let root: HTMLDivElement | undefined + let body: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let edgeRef: HTMLDivElement | undefined + let edgeAnim: AnimationPlaybackControls | undefined + let edgeTimer: ReturnType | undefined + let edgeOn = false + let mountFrame: number | undefined + let resizeFrame: number | undefined + let observer: ResizeObserver | undefined + let springTarget = -1 + const height = tunableSpringValue(0, { + type: "spring", + get visualDuration() { + return (mode === "toggle" ? toggleSpring() : spring()).visualDuration + }, + get bounce() { + return (mode === "toggle" ? toggleSpring() : spring()).bounce + }, + }) + + const gap = () => Math.max(0, props.gap ?? 0) + const grow = () => props.grow !== false + const watch = () => props.watch === true + const open = () => props.open !== false + const animateToggle = () => props.animateToggle !== false + const edge = () => props.edge === true + const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20) + const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1)) + const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320) + const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24) + const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2) + const animated = () => props.animate !== false && !reduce() + const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0 + + const stopEdgeTimer = () => { + if (edgeTimer === undefined) return + clearTimeout(edgeTimer) + edgeTimer = undefined + } + + const hideEdge = (instant = false) => { + stopEdgeTimer() + if (!edgeRef) { + edgeOn = false + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (instant || reduce()) { + edgeRef.style.opacity = "0" + edgeOn = false + return + } + if (!edgeOn) { + edgeRef.style.opacity = "0" + return + } + const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 }) + edgeAnim = current + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = "0" + edgeOn = false + }) + } + + const showEdge = () => { + stopEdgeTimer() + if (!edgeRef) return + if (reduce()) { + edgeRef.style.opacity = `${edgeOpacity()}` + edgeOn = true + return + } + if (edgeOn && edgeAnim === undefined) { + edgeRef.style.opacity = `${edgeOpacity()}` + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (!edgeOn) edgeRef.style.opacity = "0" + const current = animate( + edgeRef, + { opacity: edgeOpacity() }, + { type: "spring", visualDuration: edgeRise(), bounce: 0 }, + ) + edgeAnim = current + edgeOn = true + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = `${edgeOpacity()}` + }) + } + + const queueEdgeHide = () => { + stopEdgeTimer() + if (!edgeOn) return + if (edgeIdle() <= 0) { + hideEdge() + return + } + edgeTimer = setTimeout(() => { + edgeTimer = undefined + hideEdge() + }, edgeIdle()) + } + + const hideBody = () => { + if (!body) return + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } + + const clearBody = () => { + if (!body) return + body.style.opacity = "" + body.style.filter = "" + } + + const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { + if (props.fade === false || !body) return + if (reduce()) { + clearBody() + return + } + hideBody() + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) + fadeAnim.finished.then(() => { + if (!body || !open()) return + clearBody() + }) + } + + const setInstant = (visible: boolean) => { + const next = visible ? targetHeight() : 0 + springTarget = next + height.jump(next) + root!.style.height = visible ? "" : "0px" + root!.style.overflow = visible ? "" : "clip" + hideEdge(true) + if (visible || props.fade === false) clearBody() + else hideBody() + } + + const currentHeight = () => { + if (!root) return 0 + const v = root.style.height + if (v && v !== "auto") { + const n = Number.parseFloat(v) + if (!Number.isNaN(n)) return n + } + return Math.max(0, root.getBoundingClientRect().height) + } + + const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) + + const setHeight = (nextMode: "mount" | "toggle" = "mount") => { + if (!root || !open()) return + const next = targetHeight() + if (reduce()) { + springTarget = next + height.jump(next) + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + root.style.height = "auto" + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + if (next === springTarget) return + const prev = currentHeight() + if (Math.abs(next - prev) < 1) { + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + } + return + } + root.style.overflow = "clip" + springTarget = next + mode = nextMode + height.set(next) + } + + onMount(() => { + if (!root || !body) return + + const offChange = height.on("change", (next) => { + if (!root) return + root.style.height = `${Math.max(0, next)}px` + }) + const offStart = height.on("animationStart", () => { + if (!root) return + root.style.overflow = "clip" + root.style.willChange = "height" + root.style.contain = "layout style" + if (edgeReady()) showEdge() + }) + const offComplete = height.on("animationComplete", () => { + if (!root) return + root.style.willChange = "" + root.style.contain = "" + if (!open()) { + springTarget = 0 + root.style.height = "0px" + root.style.overflow = "clip" + return + } + const next = targetHeight() + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + if (edgeReady()) queueEdgeHide() + return + } + root.style.height = "auto" + root.style.overflow = "visible" + if (edgeReady()) queueEdgeHide() + }) + + onCleanup(() => { + offComplete() + offStart() + offChange() + }) + + if (!animated()) { + setInstant(open()) + return + } + + if (props.fade !== false) hideBody() + hideEdge(true) + + if (!open()) { + root.style.height = "0px" + root.style.overflow = "clip" + } else { + if (grow()) { + root.style.height = "0px" + root.style.overflow = "clip" + } else { + root.style.height = "auto" + root.style.overflow = "visible" + } + mountFrame = requestAnimationFrame(() => { + mountFrame = undefined + fadeBodyIn("mount") + if (grow()) setHeight("mount") + }) + } + if (watch()) { + observer = new ResizeObserver(() => { + if (!open()) return + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + setHeight("mount") + }) + }) + observer.observe(body) + } + }) + + createEffect( + on( + () => props.open, + (value) => { + if (value === undefined) return + if (!root || !body) return + if (!animateToggle() || reduce()) { + setInstant(value) + return + } + fadeAnim?.stop() + if (!value) hideEdge(true) + if (!value) { + const next = currentHeight() + if (Math.abs(next - height.get()) >= 1) { + springTarget = next + height.jump(next) + root.style.height = `${next}px` + } + if (props.fade !== false) { + fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring()) + } + root.style.overflow = "clip" + springTarget = 0 + mode = "toggle" + height.set(0) + return + } + fadeBodyIn("toggle") + setHeight("toggle") + }, + { defer: true }, + ), + ) + + createEffect(() => { + if (!edgeRef) return + edgeRef.style.height = `${edgeHeight()}px` + if (!animated() || !open() || edgeHeight() <= 0) { + hideEdge(true) + return + } + if (edge()) return + hideEdge() + }) + + createEffect(() => { + if (!root || !body) return + if (!reduce()) return + fadeAnim?.stop() + edgeAnim?.stop() + setInstant(open()) + }) + + onCleanup(() => { + stopEdgeTimer() + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + height.destroy() + fadeAnim?.stop() + edgeAnim?.stop() + edgeAnim = undefined + edgeOn = false + }) + + return ( +
+
0 ? `${gap()}px` : undefined }}> + {props.children} +
+
+
+ ) +} diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index 6a247990b38..73d83f7d72c 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -240,6 +240,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { }} on:keydown={(e) => { const event = e as KeyboardEvent + if (event.isComposing || event.keyCode === 229) return event.stopPropagation() if (e.key === "Escape") { event.preventDefault() diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 2e7b89efb21..4ba3ff796c0 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -44,6 +44,19 @@ function sanitize(html: string) { return DOMPurify.sanitize(html, config) } +function escape(text: string) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") +} + +function fallback(markdown: string) { + return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
") +} + type CopyLabels = { copy: string copied: string @@ -237,7 +250,7 @@ export function Markdown( const [html] = createResource( () => local.text, async (markdown) => { - if (isServer) return "" + if (isServer) return fallback(markdown) const hash = checksum(markdown) const key = local.cacheKey ?? hash @@ -255,7 +268,7 @@ export function Markdown( if (key && hash) touch(key, { hash, html: safe }) return safe }, - { initialValue: "" }, + { initialValue: isServer ? fallback(local.text) : "" }, ) let copyCleanup: (() => void) | undefined diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index bb809c79024..e9e6f03e903 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1,10 +1,20 @@ [data-component="assistant-message"] { content-visibility: auto; width: 100%; +} + +[data-component="assistant-parts"] { + width: 100%; + min-width: 0; display: flex; flex-direction: column; align-items: flex-start; - gap: 12px; + gap: 0; +} + +[data-component="assistant-part-item"] { + width: 100%; + min-width: 0; } [data-component="user-message"] { @@ -27,6 +37,14 @@ color: var(--text-weak); } + [data-slot="user-message-inner"] { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + gap: 4px; + } [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -35,6 +53,7 @@ width: fit-content; max-width: min(82%, 64ch); margin-left: auto; + margin-bottom: 4px; } [data-slot="user-message-attachment"] { @@ -135,7 +154,7 @@ [data-slot="user-message-copy-wrapper"] { min-height: 24px; - margin-top: 4px; + margin-top: 0; display: flex; align-items: center; justify-content: flex-end; @@ -145,7 +164,6 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; - [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; @@ -188,50 +206,23 @@ opacity: 1; pointer-events: auto; } - - .text-text-strong { - color: var(--text-strong); - } - - .font-medium { - font-weight: var(--font-weight-medium); - } } [data-component="text-part"] { width: 100%; - margin-top: 24px; + margin-top: 0; + padding-block: 4px; + position: relative; [data-slot="text-part-body"] { margin-top: 0; } - [data-slot="text-part-copy-wrapper"] { - min-height: 24px; - margin-top: 4px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - will-change: opacity; - - [data-component="tooltip-trigger"] { - display: inline-flex; - width: fit-content; - } - } - - [data-slot="text-part-meta"] { - user-select: none; - } - - [data-slot="text-part-copy-wrapper"][data-interrupted] { + [data-slot="text-part-turn-summary"] { width: 100%; justify-content: flex-end; gap: 12px; + min-width: 0; } /* kilocode_change: always show copy button for the final turn response */ @@ -252,6 +243,10 @@ } } +[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] { + padding-bottom: 0; +} + [data-component="compaction-part"] { width: 100%; display: flex; @@ -285,7 +280,6 @@ line-height: var(--line-height-normal); [data-component="markdown"] { - margin-top: 24px; font-style: normal; font-size: inherit; color: var(--text-weak); @@ -379,13 +373,16 @@ height: auto; max-height: 240px; overflow-y: auto; + overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; - + -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; &::-webkit-scrollbar { display: none; } - [data-component="markdown"] { overflow: visible; } @@ -456,7 +453,7 @@ [data-component="write-trigger"] { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; width: 100%; @@ -469,7 +466,8 @@ } [data-slot="message-part-title"] { - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -501,10 +499,34 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="message-part-meta-line"], + .message-part-meta-line { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: var(--font-weight-regular); + + [data-component="diff-changes"] { + flex-shrink: 0; + gap: 6px; + } + } + + .message-part-meta-line.soft { + [data-slot="message-part-title-filename"] { + color: var(--text-base); + } } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + font-weight: var(--font-weight-regular); + color: var(--text-strong); + flex-shrink: 0; /* kilocode_change start */ &.clickable { @@ -517,18 +539,12 @@ } } /* kilocode_change end */ - font-weight: var(--font-weight-regular); } - [data-slot="message-part-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - font-weight: var(--font-weight-regular); - } - - [data-slot="message-part-directory"] { + [data-slot="message-part-directory-inline"] { color: var(--text-weak); + min-width: 0; + max-width: min(48vw, 36ch); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -547,18 +563,6 @@ } /* kilocode_change end */ } - - [data-slot="message-part-filename"] { - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="message-part-actions"] { - display: flex; - gap: 16px; - align-items: center; - justify-content: flex-end; - } } [data-component="edit-content"] { @@ -649,6 +653,17 @@ } } +[data-slot="webfetch-meta"] { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + + [data-component="tool-action"] { + flex-shrink: 0; + } +} + [data-component="todos"] { padding: 10px 0 24px 0; display: flex; @@ -679,7 +694,6 @@ } [data-component="context-tool-group-trigger"] { - width: 100%; min-height: 24px; display: flex; align-items: center; @@ -687,28 +701,352 @@ gap: 0px; cursor: pointer; + &[data-pending] { + cursor: default; + } + [data-slot="context-tool-group-title"] { flex-shrink: 1; min-width: 0; } +} - [data-slot="collapsible-arrow"] { - color: var(--icon-weaker); +/* Prevent the trigger content from stretching full-width so the arrow sits after the text */ +[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) { + width: auto; + flex: 0 1 auto; + + [data-slot="basic-tool-tool-info"] { + flex: 0 1 auto; } } -[data-component="context-tool-group-list"] { - padding: 6px 0 4px 0; +[data-component="context-tool-step"] { + width: 100%; + min-width: 0; + padding-left: 12px; +} + +[data-component="context-tool-expanded-list"] { display: flex; flex-direction: column; - gap: 2px; + padding: 4px 0 4px 12px; + max-height: 200px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; - [data-slot="context-tool-group-item"] { - min-width: 0; - padding: 6px 0; + &::-webkit-scrollbar { + display: none; } } +[data-component="context-tool-expanded-row"] { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 22px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + + [data-slot="context-tool-expanded-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-expanded-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-base); + opacity: 0.75; + } +} + +[data-component="context-tool-rolling-row"] { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + padding-left: 12px; + + [data-slot="context-tool-rolling-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-rolling-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-weak); + } +} + +[data-component="shell-rolling-results"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + + [data-slot="shell-rolling-header-clip"] { + &:hover [data-slot="shell-rolling-actions"] { + opacity: 1; + } + + &[data-clickable="true"] { + cursor: pointer; + } + } + + [data-slot="shell-rolling-header"] { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + height: 37px; + box-sizing: border-box; + } + + [data-slot="shell-rolling-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + } + + [data-slot="shell-rolling-subtitle"] { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-normal); + line-height: var(--line-height-large); + color: var(--text-weak); + } + + [data-slot="shell-rolling-actions"] { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; + } + + .shell-rolling-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } + } + + [data-slot="shell-rolling-arrow"] { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon-weaker); + transform: rotate(-90deg); + transition: transform 0.15s ease; + } + + [data-slot="shell-rolling-arrow"][data-open="true"] { + transform: rotate(0deg); + } +} + +[data-component="shell-rolling-output"] { + width: 100%; + min-width: 0; +} + +[data-slot="shell-rolling-preview"] { + width: 100%; + min-width: 0; +} + +[data-component="shell-expanded-output"] { + width: 100%; + max-width: 100%; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +[data-component="shell-expanded-shell"] { + position: relative; + width: 100%; + min-width: 0; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + overflow: hidden; +} + +[data-slot="shell-expanded-body"] { + position: relative; + width: 100%; + min-width: 0; +} + +[data-slot="shell-expanded-top"] { + position: relative; + width: 100%; + min-width: 0; + padding: 9px 44px 9px 16px; + box-sizing: border-box; +} + +[data-slot="shell-expanded-command"] { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: 1.45; +} + +[data-slot="shell-expanded-prompt"] { + flex-shrink: 0; + color: var(--text-weaker); +} + +[data-slot="shell-expanded-input"] { + min-width: 0; + color: var(--text-strong); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +[data-slot="shell-expanded-actions"] { + position: absolute; + top: 50%; + right: 8px; + z-index: 1; + transform: translateY(-50%); +} + +.shell-expanded-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } +} + +[data-slot="shell-expanded-divider"] { + width: 100%; + height: 1px; + background: var(--border-weak-base); +} + +[data-slot="shell-expanded-pre"] { + margin: 0; + padding: 12px 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; + + code { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: 1.45; + color: var(--text-base); + } +} + +[data-component="shell-rolling-command"], +[data-component="shell-rolling-row"] { + display: inline-flex; + align-items: center; + width: 100%; + min-width: 0; + overflow: hidden; + white-space: pre; + padding-left: 12px; +} + +[data-slot="shell-rolling-text"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--font-size-small); + line-height: var(--line-height-large); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] { + color: var(--text-base); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] { + color: var(--text-weaker); +} + +[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] { + color: var(--text-weak); +} + [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -769,6 +1107,30 @@ width: 100%; } +[data-slot="assistant-part-grow"] { + width: 100%; + min-width: 0; + overflow: visible; +} + +[data-component="tool-part-wrapper"][data-tool="bash"] { + [data-component="tool-trigger"] { + width: auto; + max-width: 100%; + } + + [data-slot="basic-tool-tool-info-main"] { + align-items: center; + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-subtitle"] { + display: inline-flex; + align-items: center; + line-height: var(--line-height-large); + } +} + [data-component="dock-prompt"][data-kind="permission"] { position: relative; display: flex; @@ -1216,8 +1578,7 @@ position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; - height: 40px; - padding-bottom: 8px; + height: 37px; background-color: var(--background-stronger); } } @@ -1228,11 +1589,12 @@ } [data-slot="apply-patch-trigger-content"] { - display: flex; + display: inline-flex; align-items: center; - justify-content: space-between; - width: 100%; - gap: 20px; + justify-content: flex-start; + max-width: 100%; + min-width: 0; + gap: 8px; } [data-slot="apply-patch-file-info"] { @@ -1266,9 +1628,9 @@ [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; - gap: 16px; + gap: 8px; align-items: center; - justify-content: flex-end; + justify-content: flex-start; } [data-slot="apply-patch-change"] { @@ -1326,10 +1688,11 @@ } [data-component="tool-loaded-file"] { + min-width: 0; display: flex; align-items: center; gap: 8px; - padding: 4px 0 4px 28px; + padding: 4px 0 4px 12px; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-regular); @@ -1351,4 +1714,11 @@ } } /* kilocode_change end */ + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index aead588d809..77a3e40af46 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,41 +1,6 @@ -import { - Component, - createEffect, - createMemo, - createSignal, - For, - Match, - onMount, - Show, - Switch, - onCleanup, - Index, - type JSX, -} from "solid-js" +import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" import stripAnsi from "strip-ansi" - -// kilocode_change start - process carriage returns to simulate terminal line-overwrite behavior -// Windows CLI tools (e.g. winget) use \r to overwrite progress bars in-place. -// Without this, every progress frame renders as a separate visual line. -function processCarriageReturns(input: string): string { - // Normalize \r\n to \n first so CRLF line endings aren't treated as overwrites - const normalized = input.replace(/\r\n/g, "\n") - return normalized - .split("\n") - .map((line) => { - if (!line.includes("\r")) return line - const parts = line.split("\r") - // A trailing \r produces an empty last segment — preserve the previous visible frame - const last = parts[parts.length - 1] - if (last !== "") return last - for (let i = parts.length - 2; i >= 0; i--) { - if (parts[i] !== "") return parts[i] - } - return "" - }) - .join("\n") -} -// kilocode_change end +import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -54,61 +19,28 @@ import { import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" -import { useI18n } from "../context/i18n" -import { BasicTool } from "./basic-tool" +import { type UiI18n, useI18n } from "../context/i18n" +import { GenericTool, ToolCall } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" -import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { extractFilePathFromHref } from "../file-path" // kilocode_change import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { AnimatedCountList } from "./tool-count-summary" -import { ToolStatusTitle } from "./tool-status-title" -import { animate } from "motion" -import { useLocation } from "@solidjs/router" - -function ShellSubmessage(props: { text: string; animate?: boolean }) { - let widthRef: HTMLSpanElement | undefined - let valueRef: HTMLSpanElement | undefined - - onMount(() => { - if (!props.animate) return - requestAnimationFrame(() => { - if (widthRef) { - animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) - } - if (valueRef) { - animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) - } - }) - }) - - return ( - - - - - {props.text} - - - - - ) -} +import { list } from "./text-utils" +import { GrowBox } from "./grow-box" +import { COLLAPSIBLE_SPRING } from "./motion" +import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" +import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" +import { ShellRollingResults } from "./shell-rolling-results" interface Diagnostic { range: { @@ -149,64 +81,22 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } -export interface MessageProps { - message: MessageType - parts: PartType[] - showAssistantCopyPartID?: string | null - interrupted?: boolean - queued?: boolean - showReasoningSummaries?: boolean -} - export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean defaultOpen?: boolean showAssistantCopyPartID?: string | null - turnDurationMs?: number + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element + animate?: boolean + working?: boolean } export type PartComponent = Component export const PART_MAPPING: Record = {} -const TEXT_RENDER_THROTTLE_MS = 100 - -function createThrottledValue(getValue: () => string) { - const [value, setValue] = createSignal(getValue()) - let timeout: ReturnType | undefined - let last = 0 - - createEffect(() => { - const next = getValue() - const now = Date.now() - - const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) - if (remaining <= 0) { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - last = now - setValue(next) - return - } - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - last = Date.now() - setValue(next) - timeout = undefined - }, remaining) - }) - - onCleanup(() => { - if (timeout) clearTimeout(timeout) - }) - - return value -} - function relativizeProjectPath(path: string, directory?: string) { if (!path) return "" if (!directory) return path @@ -233,6 +123,11 @@ export type ToolInfo = { subtitle?: string } +function agentTitle(i18n: UiI18n, type?: string) { + if (!type) return i18n.t("ui.tool.agent.default") + return i18n.t("ui.tool.agent", { type }) +} + export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() switch (tool) { @@ -278,12 +173,17 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { title: i18n.t("ui.tool.codesearch"), subtitle: input.query, } - case "task": + case "task": { + const type = + typeof input.subagent_type === "string" && input.subagent_type + ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1) + : undefined return { icon: "task", - title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), + title: agentTitle(i18n, type), subtitle: input.description, } + } case "bash": return { icon: "console", @@ -328,7 +228,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: input.name || "skill", + title: i18n.t("ui.tool.skill"), + subtitle: typeof input.name === "string" ? input.name : undefined, } default: return { @@ -353,105 +254,36 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} +import { pageVisible } from "../hooks/use-page-visible" -function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -type PartRef = { - messageID: string - partID: string -} - -type PartGroup = - | { - key: string - type: "part" - ref: PartRef - } - | { - key: string - type: "context" - refs: PartRef[] - } - -function sameRef(a: PartRef, b: PartRef) { - return a.messageID === b.messageID && a.partID === b.partID -} - -function sameGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return sameRef(a.ref, b.ref) +function createGroupOpenState() { + const [state, setState] = createStore>({}) + const read = (key?: string, collapse?: boolean) => { + if (!key) return true + const value = state[key] + if (value !== undefined) return value + return !collapse } - if (b.type !== "context") return false - if (a.refs.length !== b.refs.length) return false - return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) -} - -function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((item, i) => sameGroup(item, b[i]!)) -} - -function groupParts(parts: { messageID: string; part: PartType }[]) { - const result: PartGroup[] = [] - let start = -1 - - const flush = (end: number) => { - if (start < 0) return - const first = parts[start] - const last = parts[end] - if (!first || !last) { - start = -1 - return - } - result.push({ - key: `context:${first.part.id}`, - type: "context", - refs: parts.slice(start, end + 1).map((item) => ({ - messageID: item.messageID, - partID: item.part.id, - })), - }) - start = -1 + const controlled = (key?: string) => { + if (!key) return false + return state[key] !== undefined } - - parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { - if (start < 0) start = index - return - } - - flush(index - 1) - result.push({ - key: `part:${item.messageID}:${item.part.id}`, - type: "part", - ref: { - messageID: item.messageID, - partID: item.part.id, - }, - }) - }) - - flush(parts.length - 1) - return result + const write = (key: string, value: boolean) => { + setState(key, value) + } + return { read, controlled, write } } -function partByID(parts: readonly PartType[], partID: string) { - return parts.find((part) => part.id === partID) +function shouldCollapseGroup( + statuses: (string | undefined)[], + opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, +) { + if (opts.afterTool) return true + if (opts.groupTail === false) return true + if (!pageVisible()) return false + if (opts.working) return false + if (!statuses.length) return false + return !statuses.some((s) => busy(s)) } function renderable(part: PartType, showReasoningSummaries = true) { @@ -467,7 +299,8 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write") return edit + if (tool === "apply_patch") return false } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -475,99 +308,328 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { return toolDefaultOpen(part.tool, shell, edit) } +function PartGrow(props: { + children: JSX.Element + animate?: boolean + animateToggle?: boolean + gap?: number + fade?: boolean + edge?: boolean + edgeHeight?: number + edgeOpacity?: number + edgeIdle?: number + edgeFade?: number + edgeRise?: number + grow?: boolean + watch?: boolean + open?: boolean + spring?: import("./motion").SpringConfig + toggleSpring?: import("./motion").SpringConfig +}) { + return ( + + {props.children} + + ) +} + export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null - turnDurationMs?: number + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const emptyTools: ToolPart[] = [] + const groupState = createGroupOpenState() + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record< + string, + | { + type: "part" + part: PartType + message: AssistantMessage + context?: boolean + groupKey?: string + afterTool?: boolean + groupTail?: boolean + groupParts?: { part: ToolPart; message: AssistantMessage }[] + } + | { + type: "context" + groupKey: string + parts: { part: ToolPart; message: AssistantMessage }[] + tail: boolean + afterTool: boolean + } + > = {} + const push = (key: string, item: (typeof items)[string]) => { + keys.push(key) + items[key] = item + } + const id = (part: PartType) => { + if (part.type === "tool") return part.callID || part.id + return part.id + } + const parts = props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ message, part })), + ) - const grouped = createMemo( - () => - groupParts( - props.messages.flatMap((message) => - list(data.store.part?.[message.id], emptyParts) - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: message.id, - part, - })), - ), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) + let start = -1 - const last = createMemo(() => grouped().at(-1)?.key) + const flush = (end: number, tail: boolean, afterTool: boolean) => { + if (start < 0) return + const group = parts + .slice(start, end + 1) + .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) + if (!group.length) { + start = -1 + return + } + const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` + push(groupKey, { + type: "context", + groupKey, + parts: group, + tail, + afterTool, + }) + group.forEach((entry) => { + push(`part:${entry.message.id}:${id(entry.part)}`, { + type: "part", + part: entry.part, + message: entry.message, + context: true, + groupKey, + afterTool, + groupTail: tail, + groupParts: group, + }) + }) + start = -1 + } + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1, false, (item as { part: PartType }).part.type === "tool") + push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) + }) + + flush(parts.length - 1, true, false) + return { keys, items } + }) + + const last = createMemo(() => grouped().keys.at(-1)) return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) +
+ + {(key) => { + const item = createMemo(() => grouped().items[key]) + const ctx = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "context") return + return value + }) + const part = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "part") return + return value + }) + const tail = createMemo(() => last() === key) + const tool = createMemo(() => { + const value = part() + if (!value) return false + return value.part.type === "tool" + }) + const context = createMemo(() => !!part()?.context) + const contextSpring = createMemo(() => { + const entry = part() + if (!entry?.context) return undefined + if (!groupState.controlled(entry.groupKey)) return undefined + return COLLAPSIBLE_SPRING + }) + const contextOpen = createMemo(() => { + const collapse = ( + afterTool?: boolean, + groupTail?: boolean, + group?: { part: ToolPart; message: AssistantMessage }[], + ) => + shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { + afterTool, + groupTail, + working: props.working, + }) + const value = ctx() + if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) + const entry = part() + return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) + }) + const visible = createMemo(() => { + if (!context()) return true + if (ctx()) return true + return false + }) - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() as { type: "context"; refs: PartRef[] } - return entry.refs - .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - const busy = createMemo(() => props.working && last() === entryAccessor().key) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const message = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return props.messages.find((item) => item.id === entry.ref.messageID) - }) - const part = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) - }) - - return ( - - {(msg) => ( - - {(p) => ( + const turnSummary = createMemo(() => { + const value = part() + if (!value) return false + if (value.part.type !== "text") return false + if (!props.showTurnDiffSummary) return false + return props.showAssistantCopyPartID === value.part.id + }) + const fade = createMemo(() => { + if (ctx()) return true + return tool() + }) + const edge = createMemo(() => { + const entry = part() + if (!entry) return false + if (entry.part.type !== "text") return false + if (!props.working) return false + return tail() + }) + const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) + const ctxPartsCache = new Map() + let ctxPartsPrev: ToolPart[] = [] + const ctxParts = createMemo(() => { + const parts = ctx()?.parts ?? [] + if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev + const result: ToolPart[] = [] + for (const item of parts) { + const k = item.part.callID || item.part.id + const cached = ctxPartsCache.get(k) + if (cached) { + result.push(cached) + } else { + ctxPartsCache.set(k, item.part) + result.push(item.part) + } + } + ctxPartsPrev = result + return result + }) + const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) + const ctxPending = ctxPendingRaw + const ctxHoldOpen = hold(ctxPendingRaw) + const shell = createMemo(() => { + const value = part() + if (!value) return + if (value.part.type !== "tool") return + if (value.part.tool !== "bash") return + return value.part + }) + const kind = createMemo(() => { + if (ctx()) return "context" + if (shell()) return "shell" + const value = part() + if (!value) return "part" + return value.part.type + }) + const shown = createMemo(() => { + if (ctx()) return true + if (shell()) return true + const entry = part() + if (!entry) return false + return !entry.context + }) + const partGrowProps = () => ({ + animate: props.animate, + gap: 0, + fade: fade(), + edge: edge(), + edgeHeight: 20, + edgeOpacity: 0.95, + edgeIdle: 100, + edgeFade: 0.6, + edgeRise: 0.1, + grow: true, + watch: watch(), + animateToggle: true, + open: visible(), + toggleSpring: contextSpring(), + }) + return ( + +
+ + {(entry) => ( + <> + + groupState.write(entry().groupKey, value)} + /> + + + + + )} + + {(value) => } + + {(entry) => ( + + +
- )} - - )} - - ) - })()} - - - ) - }} - +
+
+
+ )} +
+
+
+ ) + }} +
+
) } @@ -575,76 +637,6 @@ function isContextGroupTool(part: PartType): part is ToolPart { return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) } -function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) - if (info.subtitle) return info.subtitle - if (part.state.status === "error") return part.state.error - if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) - return part.state.title - const description = part.state.input?.description - if (typeof description === "string") return description - return undefined -} - -function contextToolTrigger(part: ToolPart, i18n: ReturnType) { - const input = (part.state.input ?? {}) as Record - const path = typeof input.path === "string" ? input.path : "/" - const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const pattern = typeof input.pattern === "string" ? input.pattern : undefined - const include = typeof input.include === "string" ? input.include : undefined - const offset = typeof input.offset === "number" ? input.offset : undefined - const limit = typeof input.limit === "number" ? input.limit : undefined - - switch (part.tool) { - case "read": { - const args: string[] = [] - if (offset !== undefined) args.push("offset=" + offset) - if (limit !== undefined) args.push("limit=" + limit) - return { - title: i18n.t("ui.tool.read"), - subtitle: filePath ? getFilename(filePath) : "", - args, - } - } - case "list": - return { - title: i18n.t("ui.tool.list"), - subtitle: getDirectory(path), - } - case "glob": - return { - title: i18n.t("ui.tool.glob"), - subtitle: getDirectory(path), - args: pattern ? ["pattern=" + pattern] : [], - } - case "grep": { - const args: string[] = [] - if (pattern) args.push("pattern=" + pattern) - if (include) args.push("include=" + include) - return { - title: i18n.t("ui.tool.grep"), - subtitle: getDirectory(path), - args, - } - } - default: { - const info = getToolInfo(part.tool, input) - return { - title: info.title, - subtitle: info.subtitle || contextToolDetail(part), - args: [], - } - } - } -} - -function contextToolSummary(parts: ToolPart[]) { - const read = parts.filter((part) => part.tool === "read").length - const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length - const list = parts.filter((part) => part.tool === "list").length - return { read, search, list } -} - function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -675,210 +667,11 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } -export function Message(props: MessageProps) { - return ( - - - {(userMessage) => ( - - )} - - - {(assistantMessage) => ( - - )} - - - ) -} - -export function AssistantMessageDisplay(props: { - message: AssistantMessage - parts: PartType[] - showAssistantCopyPartID?: string | null - showReasoningSummaries?: boolean -}) { - const emptyTools: ToolPart[] = [] - const grouped = createMemo( - () => - groupParts( - props.parts - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: props.message.id, - part, - })), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) - - return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) - - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() as { type: "context"; refs: PartRef[] } - return entry.refs - .map((ref) => partByID(props.parts, ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const part = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return partByID(props.parts, entry.ref.partID) - }) - - return ( - - {(p) => ( - - )} - - ) - })()} - - - ) - }} - - ) -} - -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { - const i18n = useI18n() - const [open, setOpen] = createSignal(false) - const pending = createMemo( - () => - !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), - ) - const summary = createMemo(() => contextToolSummary(props.parts)) - - return ( - - -
- - - - - - - - - -
-
- -
- - {(partAccessor) => { - const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) - const running = createMemo( - () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", - ) - return ( -
-
-
-
-
-
- - - - - {trigger().subtitle} - - - - {(arg) => {arg}} - - -
-
-
-
-
-
- ) - }} -
-
-
-
- ) -} - export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean + animate?: boolean queued?: boolean onRevert?: () => void }) { @@ -929,14 +722,9 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const metaHead = createMemo(() => { + const userMeta = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] - return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") - }) - - const metaTail = createMemo(() => { - const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -953,108 +741,83 @@ export function UserMessageDisplay(props: { } return ( -
- 0}> -
- - {(file) => ( -
{ - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
- } - > - {file.filename - -
- )} - -
- - - <> -
-
- + +
+
+ 0}> +
+ + {(file) => ( +
{ + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + + +
+ } + > + {file.filename + +
+ )} +
- -
- + + + <> +
+
+ +
+ +
+ +
+
-
-
-
- - - +
+ - {metaHead()} + {userMeta()} - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} - - - - - - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - props.onRevert?.() - }} - aria-label={i18n.t("ui.message.revert")} - /> - - - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - -
- -
-
+ + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+ + +
+
+ ) } @@ -1108,7 +871,10 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} - turnDurationMs={props.turnDurationMs} + showTurnDiffSummary={props.showTurnDiffSummary} + turnDiffSummary={props.turnDiffSummary} + animate={props.animate} + working={props.working} />
) @@ -1118,12 +884,16 @@ export interface ToolProps { input: Record metadata: Record tool: string + partID?: string + callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean + animate?: boolean + reveal?: boolean } export type ToolComponent = Component @@ -1157,7 +927,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -1186,76 +956,28 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre ) } -// kilocode_change start -function McpTool(props: ToolProps) { - return ( - - - {(output) => ( -
- -
- )} -
-
- ) -} -// kilocode_change end - PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = () => props.part as ToolPart - if (part().tool === "todowrite" || part().tool === "todoread") return null - - const hideQuestion = createMemo( - () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), - ) + const part = props.part as ToolPart + const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) const emptyInput: Record = {} const emptyMetadata: Record = {} - const input = () => part().state?.input ?? emptyInput + const input = () => part.state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => part.state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part().tool) ?? McpTool) // kilocode_change - - // kilocode_change start - const dismissed = createMemo(() => { - if (part().tool !== "question") return false - const s = part().state - return s.status === "error" && s.error.includes("dismissed this question") - }) - - if (dismissed()) { - return ( - -
-
- {i18n.t("ui.tool.questions")} dismissed -
-
-
- ) - } - // kilocode_change end + const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) return ( -
+
- + {(error) => { const cleaned = error().replace("Error: ", "") - if (part().tool === "question" && cleaned.includes("dismissed this question")) { + if (part.tool === "question" && cleaned.includes("dismissed this question")) { return (
@@ -1289,13 +1011,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { @@ -1320,143 +1046,29 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { } PART_MAPPING["text"] = function TextPartDisplay(props) { - const data = useData() - const i18n = useI18n() const part = () => props.part as TextPart - const interrupted = createMemo( - () => - props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", - ) - - const model = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const match = data.store.provider?.all?.find((p) => p.id === message.providerID) - return match?.models?.[message.modelID]?.name ?? message.modelID - }) - - const duration = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const completed = message.time.completed - const ms = - typeof props.turnDurationMs === "number" - ? props.turnDurationMs - : typeof completed === "number" - ? completed - message.time.created - : -1 - if (!(ms >= 0)) return "" - const total = Math.round(ms / 1000) - if (total < 60) return `${total}s` - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return `${minutes}m ${seconds}s` - }) - - const meta = createMemo(() => { - if (props.message.role !== "assistant") return "" - const agent = (props.message as AssistantMessage).agent - const items = [ - agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", - model(), - duration(), - interrupted() ? i18n.t("ui.message.interrupted") : "", - ] - return items.filter((x) => !!x).join(" \u00B7 ") - }) const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const isLastTextPart = createMemo(() => { - const last = (data.store.part?.[props.message.id] ?? []) - .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) - .at(-1) - return last?.id === part().id + const summary = createMemo(() => { + if (props.message.role !== "assistant") return + if (!props.showTurnDiffSummary) return + if (props.showAssistantCopyPartID !== part().id) return + return props.turnDiffSummary }) - const showCopy = createMemo(() => { - if (props.message.role !== "assistant") return isLastTextPart() - if (props.showAssistantCopyPartID === null) return false - if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id - return isLastTextPart() - }) - const [copied, setCopied] = createSignal(false) - - const handleCopy = async () => { - const content = displayText() - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - // kilocode_change start - const handleMarkdownClick = (e: MouseEvent) => { - if (!data.openFile) return - const target = e.target - if (!(target instanceof HTMLElement)) return - // Handle .file-link code spans (e.g. `src/foo.ts:42`) - const fileLink = target.closest(".file-link[data-file-path]") - if (fileLink) { - const path = fileLink.getAttribute("data-file-path") - if (!path) return - const lineAttr = fileLink.getAttribute("data-file-line") - const colAttr = fileLink.getAttribute("data-file-col") - const line = lineAttr ? parseInt(lineAttr, 10) : undefined - const column = colAttr ? parseInt(colAttr, 10) : undefined - data.openFile(path, line, column) - return - } - // Handle markdown links whose href looks like a relative file path - // (e.g. [AGENTS.md](AGENTS.md) or [src/foo.ts](src/foo.ts)) - const anchor = target.closest("a.external-link") as HTMLAnchorElement | null - if (anchor) { - const href = anchor.getAttribute("href") - if (!href) return - const filePath = extractFilePathFromHref(href) - if (!filePath) return - e.preventDefault() - data.openFile(filePath) - } - } - // kilocode_change end return (
- {/* kilocode_change */} +
- - {/* kilocode_change: data-is-turn-copy makes the copy button always visible for the final response */} -
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
+ + {(render) => ( + +
{render()()}
+
+ )}
@@ -1465,11 +1077,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { const part = () => props.part as ReasoningPart - // kilocode_change start - // Filter out redacted reasoning chunks from OpenRouter - // OpenRouter sends encrypted reasoning data that appears as [REDACTED] - const text = () => part().text.replace("[REDACTED]", "").trim() - // kilocode_change end + const text = () => part().text.trim() const throttledText = createThrottledValue(text) return ( @@ -1490,39 +1098,33 @@ ToolRegistry.register({ if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) const loaded = createMemo(() => { - if (props.status !== "completed") return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) + const pending = createMemo(() => busy(props.status)) return ( <> - data.openFile!(props.input.filePath) : undefined + trigger={ + } - // kilocode_change end /> {(filepath) => ( -
data.openFile?.(filepath)} // kilocode_change - > - - - {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} - -
+ )}
@@ -1534,11 +1136,20 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - + } > {(output) => ( @@ -1547,7 +1158,7 @@ ToolRegistry.register({
)} - + ) }, }) @@ -1556,15 +1167,21 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - + } > {(output) => ( @@ -1602,25 +1225,191 @@ ToolRegistry.register({
)} - + ) }, }) +function useToolReveal(pending: () => boolean, animate?: () => boolean) { + const enabled = () => animate?.() ?? true + const [live, setLive] = createSignal(pending() || enabled()) + createEffect(() => { + if (pending()) setLive(true) + }) + return () => enabled() && live() +} + +function WebfetchMeta(props: { url: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + event.stopPropagation()} + > + {props.url} + +
+ +
+
+ ) +} + +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolText(props: { text: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolLoadedFile(props: { text: string; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) + + return ( + +
+ + {props.text} +
+
+ ) +} + +function ToolTriggerRow(props: { + title: string + pending: boolean + subtitle?: string + args?: string[] + action?: JSX.Element + animate?: boolean + revealOnMount?: boolean +}) { + const reveal = useToolReveal( + () => props.pending, + () => props.animate !== false, + ) + const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) + const detailAnimate = createMemo(() => { + if (props.animate === false) return false + if (props.revealOnMount) return true + if (!props.pending && !reveal()) return true + return reveal() + }) + + return ( +
+
+ + + + {(text) => } +
+ {props.action} +
+ ) +} + +type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] + +function ToolMetaLine(props: { + filename: string + path?: string + changes?: DiffValue + delay?: number + animate?: boolean + soft?: boolean +}) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) + + return ( + + {props.filename} + + {props.path} + + {(changes) => } + + ) +} + +function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.04, animate: props.animate }) + + return ( +
+ +
+ ) +} + +function ShellText(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + + + {props.text} + + + + ) +} + ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" return value }) return ( - @@ -1628,24 +1417,8 @@ ToolRegistry.register({ - - event.stopPropagation()} - > - {url()} - - + {(value) => }
- -
- -
-
} /> @@ -1664,7 +1437,8 @@ ToolRegistry.register({ }) return ( - - +
) }, }) @@ -1690,7 +1464,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1710,15 +1485,20 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() - const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined - const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) + const type = createMemo(() => { + const raw = props.input.subagent_type + if (typeof raw !== "string" || !raw) return undefined + return raw[0]!.toUpperCase() + raw.slice(1) + }) + const title = createMemo(() => agentTitle(i18n, type())) const description = createMemo(() => { const value = props.input.description if (typeof value === "string") return value return undefined }) - const running = createMemo(() => props.status === "pending" || props.status === "running") + const running = createMemo(() => busy(props.status)) + const reveal = useToolReveal(running, () => props.reveal !== false) const href = createMemo(() => { const sessionId = childSessionId() @@ -1727,36 +1507,49 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - const path = location.pathname + if (typeof window === "undefined") return + const path = window.location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}` }) - const titleContent = () => + const handleLinkClick = (e: MouseEvent) => { + const sessionId = childSessionId() + const url = href() + if (!sessionId || !url) return + + e.stopPropagation() + + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const nav = data.navigateToSession + if (!nav || typeof window === "undefined") return + + e.preventDefault() + const before = window.location.pathname + window.location.search + window.location.hash + nav(sessionId) + setTimeout(() => { + const after = window.location.pathname + window.location.search + window.location.hash + if (after === before) window.location.assign(url) + }, 50) + } const trigger = () => (
- - {titleContent()} + + {(url) => ( - e.stopPropagation()} - > - {description()} - + )} - {description()} + @@ -1764,7 +1557,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -1772,38 +1565,51 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - const pending = () => props.status === "pending" || props.status === "running" - const sawPending = pending() - // kilocode_change start - separate cmd/output memos so copy excludes the "$ " display prefix - const cmd = createMemo(() => props.input.command ?? props.metadata.command ?? "") - const out = createMemo(() => processCarriageReturns(stripAnsi(props.output || props.metadata.output || ""))) - const text = createMemo(() => `$ ${cmd()}${out() ? "\n\n" + out() : ""}`) - // kilocode_change end + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) + const subtitle = () => props.input.description ?? props.metadata.description + const cmd = createMemo(() => { + const value = props.input.command ?? props.metadata.command + if (typeof value === "string") return value + return "" + }) + const output = createMemo(() => { + if (typeof props.output === "string") return props.output + if (typeof props.metadata.output === "string") return props.metadata.output + return "" + }) + const command = createMemo(() => `$ ${cmd()}`) + const result = createMemo(() => stripAnsi(output())) + const text = createMemo(() => { + const value = result() + return `${command()}${value ? "\n\n" + value : ""}` + }) + const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { - const command = cmd() // kilocode_change - if (!command) return - const content = out() ? `${command}\n\n${out()}` : command // kilocode_change + const content = text() + if (!content) return await navigator.clipboard.writeText(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } return ( -
- - - + {(text) => }
} @@ -1831,7 +1637,7 @@ ToolRegistry.register({
- + ) }, }) @@ -1839,24 +1645,17 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { - const data = useData() // kilocode_change const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - // kilocode_change start - const handleFileClick = (e: MouseEvent) => { - if (!data.openFile || !props.input.filePath) return - e.stopPropagation() - data.openFile(props.input.filePath) - } - // kilocode_change end - - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
- - - {/* kilocode_change start */} - - {filename()} - - {/* kilocode_change end */} + + {(name) => ( + + )}
- -
- {/* kilocode_change start */} - - {getDirectory(props.input.filePath!)} - - {/* kilocode_change end */} -
-
-
-
- - -
} @@ -1905,7 +1685,9 @@ ToolRegistry.register({ {(diff) => }
+ + {(diff) => } + } >
@@ -1925,7 +1707,7 @@ ToolRegistry.register({ - +
) }, @@ -1934,24 +1716,17 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { - const data = useData() // kilocode_change const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - // kilocode_change start - const handleFileClick = (e: MouseEvent) => { - if (!data.openFile || !props.input.filePath) return - e.stopPropagation() - data.openFile(props.input.filePath) - } - // kilocode_change end - - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
- - - {/* kilocode_change start */} - - {filename()} - - {/* kilocode_change end */} + + {(name) => ( + + )}
- -
- {/* kilocode_change start */} - - {getDirectory(props.input.filePath!)} - - {/* kilocode_change end */} -
-
-
{/* */}
} > @@ -2009,7 +1768,7 @@ ToolRegistry.register({ - +
) }, @@ -2030,11 +1789,11 @@ interface ApplyPatchFile { ToolRegistry.register({ name: "apply_patch", render(props) { - const data = useData() // kilocode_change const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -2042,7 +1801,6 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false - createEffect(() => { const list = files() if (list.length === 0) return @@ -2050,7 +1808,6 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) - const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -2058,24 +1815,44 @@ ToolRegistry.register({ }) return ( - - +
+ +
+
+ + + + + {(file) => ( + + )} + + {(text) => } +
+
+
+ } + > + 0}> setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -2083,13 +1860,11 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) - createEffect(() => { if (!active()) { setVisible(false) return } - requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -2127,19 +1902,6 @@ ToolRegistry.register({ {i18n.t("ui.patch.action.moved")} - {/* kilocode_change start */} - { - if (!data.openFile) return - e.stopPropagation() - data.openFile(file.filePath) - }} - > - {file.relativePath} - - {/* kilocode_change end */} @@ -2167,41 +1929,9 @@ ToolRegistry.register({ -
- - } - > - {(file) => ( -
- -
-
- - - - - {getFilename(file().relativePath)} - -
- -
- {getDirectory(file().relativePath)} -
-
-
-
- - - -
-
- } - > + } + > + {(file) => ( - + } @@ -2236,10 +1969,10 @@ ToolRegistry.register({ /> - - - )} -
+ )} + + + ) }, }) @@ -2257,6 +1990,7 @@ ToolRegistry.register({ return [] }) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -2265,14 +1999,19 @@ ToolRegistry.register({ }) return ( - + } >
@@ -2290,7 +2029,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2302,6 +2041,7 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2311,14 +2051,19 @@ ToolRegistry.register({ }) return ( - + } >
@@ -2335,7 +2080,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2343,21 +2088,28 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const title = createMemo(() => props.input.name || "skill") - const running = createMemo(() => props.status === "pending" || props.status === "running") - - const titleContent = () => - - const trigger = () => ( -
-
- - {titleContent()} - -
-
+ const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> ) - - return }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index a5104a1a3ef..5deefcfa61c 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,8 +1,9 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" -type Opt = Partial> +type Opt = Pick const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) + const reduce = prefersReducedMotion const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let stop = attachSpring(spring, source, config) - let off = spring.on("change", (next: number) => setValue(next)) + let reduced = reduce() + let stop = reduced ? () => {} : attachSpring(spring, source, config) + let off = spring.on("change", (next) => setValue(next)) createEffect(() => { - source.set(target()) + const next = target() + if (reduced) { + source.set(next) + spring.set(next) + setValue(next) + return + } + source.set(next) }) createEffect(() => { - if (!options) return const next = read() - if (eq(config, next)) return + const skip = reduce() + if (eq(config, next) && reduced === skip) return config = next + reduced = skip stop() - stop = attachSpring(spring, source, next) + stop = skip ? () => {} : attachSpring(spring, source, next) + if (skip) { + const value = target() + source.set(value) + spring.set(value) + setValue(value) + return + } setValue(spring.get()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx new file mode 100644 index 00000000000..6cdf01c7314 --- /dev/null +++ b/packages/ui/src/components/motion.tsx @@ -0,0 +1,77 @@ +import { followValue } from "motion" +import type { MotionValue } from "motion" + +export { animate, springValue } from "motion" +export type { AnimationPlaybackControls } from "motion" + +/** + * Like `springValue` but preserves getters on the config object. + * `springValue` spreads config at creation, snapshotting getter values. + * This passes the config through to `followValue` intact, so getters + * on `visualDuration` etc. fire on every `.set()` call. + */ +export function tunableSpringValue(initial: T, config: SpringConfig): MotionValue { + return followValue(initial, config as any) +} + +let _growDuration = 0.5 +let _collapsibleDuration = 0.3 + +export const GROW_SPRING = { + type: "spring" as const, + get visualDuration() { + return _growDuration + }, + bounce: 0, +} + +export const COLLAPSIBLE_SPRING = { + type: "spring" as const, + get visualDuration() { + return _collapsibleDuration + }, + bounce: 0, +} + +export const setGrowDuration = (v: number) => { + _growDuration = v +} +export const setCollapsibleDuration = (v: number) => { + _collapsibleDuration = v +} +export const getGrowDuration = () => _growDuration +export const getCollapsibleDuration = () => _collapsibleDuration + +export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number } + +export const FAST_SPRING = { + type: "spring" as const, + visualDuration: 0.35, + bounce: 0, +} + +export const GLOW_SPRING = { + type: "spring" as const, + visualDuration: 0.4, + bounce: 0.15, +} + +export const WIPE_MASK = + "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" + +export const clearMaskStyles = (el: HTMLElement) => { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + el.style.maskSize = "" + el.style.webkitMaskSize = "" + el.style.maskRepeat = "" + el.style.webkitMaskRepeat = "" + el.style.maskPosition = "" + el.style.webkitMaskPosition = "" +} + +export const clearFadeStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.filter = "" + el.style.transform = "" +} diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css new file mode 100644 index 00000000000..200b2a97e93 --- /dev/null +++ b/packages/ui/src/components/rolling-results.css @@ -0,0 +1,92 @@ +[data-component="rolling-results"] { + --rolling-results-row-height: 22px; + --rolling-results-fixed-height: var(--rolling-results-row-height); + --rolling-results-fixed-gap: 0px; + --rolling-results-row-gap: 0px; + + display: block; + width: 100%; + min-width: 0; + + [data-slot="rolling-results-viewport"] { + position: relative; + min-width: 0; + height: 0; + overflow: clip; + } + + &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] { + mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--rolling-results-fade), + black calc(100% - calc(var(--rolling-results-fade) * 0.5)), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--rolling-results-fade), + black calc(100% - calc(var(--rolling-results-fade) * 0.5)), + transparent 100% + ); + } + + [data-slot="rolling-results-fixed"] { + min-width: 0; + height: var(--rolling-results-fixed-height); + min-height: var(--rolling-results-fixed-height); + display: flex; + align-items: center; + } + + [data-slot="rolling-results-window"] { + min-width: 0; + margin-top: var(--rolling-results-fixed-gap); + height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap)); + overflow: clip; + } + + &[data-scrollable="true"] [data-slot="rolling-results-window"] { + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + } + + &[data-scrollable="true"] [data-slot="rolling-results-track"] { + transform: none !important; + will-change: auto; + } + + [data-slot="rolling-results-body"] { + min-width: 0; + } + + [data-slot="rolling-results-track"] { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--rolling-results-row-gap); + will-change: transform; + } + + [data-slot="rolling-results-row"], + [data-slot="rolling-results-empty"] { + min-width: 0; + height: var(--rolling-results-row-height); + min-height: var(--rolling-results-row-height); + display: flex; + align-items: center; + } + + [data-slot="rolling-results-row"] { + color: var(--text-base); + } + + [data-slot="rolling-results-empty"] { + color: var(--text-weaker); + } +} diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx new file mode 100644 index 00000000000..d2f30105e5e --- /dev/null +++ b/packages/ui/src/components/rolling-results.tsx @@ -0,0 +1,326 @@ +import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" +import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" + +export type RollingResultsProps = { + items: T[] + render: (item: T, index: number) => JSX.Element + fixed?: JSX.Element + getKey?: (item: T, index: number) => string + rows?: number + rowHeight?: number + fixedHeight?: number + rowGap?: number + open?: boolean + scrollable?: boolean + spring?: SpringConfig + animate?: boolean + class?: string + empty?: JSX.Element + noFadeOnCollapse?: boolean +} + +export function RollingResults(props: RollingResultsProps) { + let view: HTMLDivElement | undefined + let track: HTMLDivElement | undefined + let windowEl: HTMLDivElement | undefined + let shift: AnimationPlaybackControls | undefined + let resize: AnimationPlaybackControls | undefined + let edgeFade: AnimationPlaybackControls | undefined + + const reducedMotion = prefersReducedMotion + + const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3))) + const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22))) + const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight()))) + const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0))) + const fixed = createMemo(() => props.fixed !== undefined) + const list = createMemo(() => props.items ?? []) + const count = createMemo(() => list().length) + + // scrollReady is the internal "transition complete" state. + // It only becomes true after props.scrollable is true AND the offset animation has settled. + const [scrollReady, setScrollReady] = createSignal(false) + + const backstop = createMemo(() => Math.max(rows() * 2, 12)) + const rendered = createMemo(() => { + const items = list() + if (scrollReady()) return items + const max = backstop() + return items.length > max ? items.slice(-max) : items + }) + const skipped = createMemo(() => { + if (scrollReady()) return 0 + return count() - rendered().length + }) + const open = createMemo(() => props.open !== false) + const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion()) + const noFade = () => props.noFadeOnCollapse === true + const overflowing = createMemo(() => count() > rows()) + const shown = createMemo(() => Math.min(rows(), count())) + const step = createMemo(() => rowHeight() + rowGap()) + const offset = createMemo(() => Math.max(0, count() - shown()) * step()) + const body = createMemo(() => { + if (shown() > 0) { + return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap() + } + if (props.empty === undefined) return 0 + return rowHeight() + }) + const gap = createMemo(() => { + if (!fixed()) return 0 + if (body() <= 0) return 0 + return rowGap() + }) + const height = createMemo(() => { + if (!open()) return 0 + if (!fixed()) return body() + return fixedHeight() + gap() + body() + }) + + const key = (item: T, index: number) => { + const value = props.getKey + if (value) return value(item, index) + return String(index) + } + + const setTrack = (value: number) => { + if (!track) return + track.style.transform = `translateY(${-Math.round(value)}px)` + } + + const setView = (value: number) => { + if (!view) return + view.style.height = `${Math.max(0, Math.round(value))}px` + } + + onMount(() => { + setTrack(offset()) + }) + + // Original WAAPI offset animation — untouched rolling behavior. + createEffect( + on( + offset, + (next) => { + if (!track) return + if (scrollReady()) return + if (props.scrollable) return + if (!active()) { + shift?.stop() + shift = undefined + setTrack(next) + return + } + shift?.stop() + const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING) + shift = anim + anim.finished + .catch(() => {}) + .finally(() => { + if (shift !== anim) return + setTrack(next) + shift = undefined + }) + }, + { defer: true }, + ), + ) + + // Scrollable transition: wait for the offset animation to finish, + // then batch all DOM changes in one synchronous pass. + createEffect( + on( + () => props.scrollable === true, + (isScrollable) => { + if (!isScrollable) { + setScrollReady(false) + if (windowEl) { + windowEl.style.overflowY = "" + windowEl.style.maskImage = "" + windowEl.style.webkitMaskImage = "" + } + return + } + // Wait for the current offset animation to settle (if any). + const done = shift?.finished ?? Promise.resolve() + done + .catch(() => {}) + .then(() => { + if (props.scrollable !== true) return + + // Batch the signal update — Solid updates the DOM synchronously: + // rendered() returns all items, skipped() returns 0, padding-top removed, + // data-scrollable becomes "true". + batch(() => setScrollReady(true)) + + // Now the DOM has all items. Safe to switch layout strategy. + // CSS handles `transform: none !important` on [data-scrollable="true"]. + if (windowEl) { + windowEl.style.overflowY = "auto" + windowEl.scrollTop = windowEl.scrollHeight + } + updateScrollMask() + }) + }, + ), + ) + + // Auto-scroll to bottom when new items arrive in scrollable mode + const [userScrolled, setUserScrolled] = createSignal(false) + + const updateScrollMask = () => { + if (!windowEl) return + if (!scrollReady()) { + windowEl.style.maskImage = "" + windowEl.style.webkitMaskImage = "" + return + } + const { scrollTop, scrollHeight, clientHeight } = windowEl + const atBottom = scrollHeight - scrollTop - clientHeight < 8 + // Top fade is always present in scrollable mode (matches rolling mode appearance). + // Bottom fade only when not scrolled to the end. + const mask = atBottom + ? "linear-gradient(to bottom, transparent 0, black 8px)" + : "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)" + windowEl.style.maskImage = mask + windowEl.style.webkitMaskImage = mask + } + + createEffect(() => { + if (!scrollReady()) { + setUserScrolled(false) + return + } + const _n = count() + const scrolled = userScrolled() + if (scrolled) return + if (windowEl) { + windowEl.scrollTop = windowEl.scrollHeight + updateScrollMask() + } + }) + + const onWindowScroll = () => { + if (!windowEl || !scrollReady()) return + const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8 + setUserScrolled(!atBottom) + updateScrollMask() + } + + const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)" + const applyEdge = () => { + if (!view) return + edgeFade?.stop() + edgeFade = undefined + view.style.maskImage = EDGE_MASK + view.style.webkitMaskImage = EDGE_MASK + view.style.maskSize = "100% 100%" + view.style.maskRepeat = "no-repeat" + } + const clearEdge = () => { + if (!view) return + if (!active()) { + clearMaskStyles(view) + return + } + edgeFade?.stop() + const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING) + edgeFade = anim + anim.finished + .catch(() => {}) + .then(() => { + if (edgeFade !== anim || !view) return + clearMaskStyles(view) + edgeFade = undefined + }) + } + + createEffect( + on(height, (next, prev) => { + if (!view) return + if (!active()) { + resize?.stop() + resize = undefined + setView(next) + view.style.opacity = "" + clearEdge() + return + } + const collapsing = next === 0 && prev !== undefined && prev > 0 + const expanding = prev === 0 && next > 0 + resize?.stop() + view.style.opacity = "" + applyEdge() + const spring = props.spring ?? GROW_SPRING + const anim = collapsing + ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring) + : expanding + ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring) + : animate(view, { height: `${next}px` }, spring) + resize = anim + anim.finished + .catch(() => {}) + .finally(() => { + view.style.opacity = "" + if (resize !== anim) return + setView(next) + resize = undefined + clearEdge() + }) + }), + ) + + onCleanup(() => { + shift?.stop() + resize?.stop() + edgeFade?.stop() + shift = undefined + resize = undefined + edgeFade = undefined + }) + + return ( +
+
+ +
{props.fixed}
+
+
+
+ +
{props.empty}
+
+
+ + {(item, index) => ( +
+ {props.render(item, index())} +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index f6a49e241c6..a8574cc9f7b 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -9,6 +9,13 @@ overflow-y: auto; scrollbar-width: none; outline: none; + display: block; + overflow-anchor: none; +} + +.scroll-view__viewport[data-reverse="true"] { + display: flex; + flex-direction: column-reverse; } .scroll-view__viewport::-webkit-scrollbar { @@ -45,18 +52,6 @@ background-color: var(--border-strong-base); } -.dark .scroll-view__thumb::after, -[data-theme="dark"] .scroll-view__thumb::after { - background-color: var(--border-weak-base); -} - -.dark .scroll-view__thumb:hover::after, -[data-theme="dark"] .scroll-view__thumb:hover::after, -.dark .scroll-view__thumb[data-dragging="true"]::after, -[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after { - background-color: var(--border-strong-base); -} - .scroll-view__thumb[data-visible="true"] { opacity: 1; } diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 52ed39a465f..a8d3cf0f841 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,17 +1,18 @@ -import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js" +import { animate, type AnimationPlaybackControls } from "motion" import { useI18n } from "../context/i18n" +import { FAST_SPRING } from "./motion" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void - orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + reverse?: boolean } export function ScrollView(props: ScrollViewProps) { const i18n = useI18n() - const merged = mergeProps({ orientation: "vertical" }, props) const [local, events, rest] = splitProps( - merged, - ["class", "children", "viewportRef", "orientation", "style"], + props, + ["class", "children", "viewportRef", "style", "reverse"], [ "onScroll", "onWheel", @@ -25,9 +26,9 @@ export function ScrollView(props: ScrollViewProps) { ], ) - let rootRef!: HTMLDivElement let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement + let anim: AnimationPlaybackControls | undefined const [isHovered, setIsHovered] = createSignal(false) const [isDragging, setIsDragging] = createSignal(false) @@ -36,6 +37,8 @@ export function ScrollView(props: ScrollViewProps) { const [thumbTop, setThumbTop] = createSignal(0) const [showThumb, setShowThumb] = createSignal(false) + const reverse = () => local.reverse === true + const updateThumb = () => { if (!viewportRef) return const { scrollTop, scrollHeight, clientHeight } = viewportRef @@ -57,9 +60,13 @@ export function ScrollView(props: ScrollViewProps) { const maxScrollTop = scrollHeight - clientHeight const maxThumbTop = trackHeight - height - const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 + const top = (() => { + if (maxScrollTop <= 0) return 0 + if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop + return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop + })() - // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety) + // Ensure thumb stays within bounds const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop)) setThumbHeight(height) @@ -82,6 +89,7 @@ export function ScrollView(props: ScrollViewProps) { } onCleanup(() => { + stop() observer.disconnect() }) @@ -123,6 +131,31 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointerup", onPointerUp) } + const stop = () => { + if (!anim) return + anim.stop() + anim = undefined + } + + const limit = (top: number) => { + const max = viewportRef.scrollHeight - viewportRef.clientHeight + if (reverse()) return Math.max(-max, Math.min(0, top)) + return Math.max(0, Math.min(max, top)) + } + + const glide = (top: number) => { + stop() + anim = animate(viewportRef.scrollTop, limit(top), { + ...FAST_SPRING, + onUpdate: (v) => { + viewportRef.scrollTop = v + }, + onComplete: () => { + anim = undefined + }, + }) + } + // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -147,11 +180,11 @@ export function ScrollView(props: ScrollViewProps) { break case "Home": e.preventDefault() - viewportRef.scrollTo({ top: 0, behavior: "smooth" }) + glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0) break case "End": e.preventDefault() - viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) + glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight) break case "ArrowUp": e.preventDefault() @@ -166,7 +199,6 @@ export function ScrollView(props: ScrollViewProps) { return (
setIsHovered(true)} @@ -177,16 +209,26 @@ export function ScrollView(props: ScrollViewProps) {
{ updateThumb() if (typeof events.onScroll === "function") events.onScroll(e as any) }} - onWheel={events.onWheel as any} - onTouchStart={events.onTouchStart as any} + onWheel={(e) => { + if (e.deltaY) stop() + if (typeof events.onWheel === "function") events.onWheel(e as any) + }} + onTouchStart={(e) => { + stop() + if (typeof events.onTouchStart === "function") events.onTouchStart(e as any) + }} onTouchMove={events.onTouchMove as any} onTouchEnd={events.onTouchEnd as any} onTouchCancel={events.onTouchCancel as any} - onPointerDown={events.onPointerDown as any} + onPointerDown={(e) => { + stop() + if (typeof events.onPointerDown === "function") events.onPointerDown(e as any) + }} onClick={events.onClick as any} tabIndex={0} role="region" diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index e1dc5012ed5..0a70bb59c6c 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => { const searchHandles = new Map() const readyFiles = new Set() const [store, setStore] = createStore<{ open: string[]; force: Record }>({ - open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), + open: [], force: {}, }) @@ -554,7 +554,9 @@ export const SessionReview = (props: SessionReviewProps) => { return (
-
{props.title ?? i18n.t("ui.sessionReview.title")}
+
+ {props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title} +
[data-component="text-shimmer"] { + flex: 0 0 auto; + white-space: nowrap; + } + } + + [data-slot="session-turn-handoff-wrap"] { + width: 100%; + min-width: 0; + overflow: visible; + } + + [data-slot="session-turn-handoff"] { + width: 100%; + min-width: 0; + min-height: 37px; + position: relative; + } + + [data-slot="session-turn-thinking"] { + position: absolute; + inset: 0; + will-change: opacity, filter; + transition: + opacity 180ms ease-out, + filter 180ms ease-out, + transform 180ms ease-out; + } + + [data-slot="session-turn-thinking"][data-visible="false"] { + opacity: 0; + filter: blur(2px); + transform: translateY(1px); + pointer-events: none; + } + + [data-slot="session-turn-thinking"][data-visible="true"] { + opacity: 1; + filter: blur(0px); + transform: translateY(0px); + } + + [data-slot="session-turn-meta"] { + position: absolute; + inset: 0; + min-height: 37px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + [data-slot="session-turn-meta"][data-interrupted] { + gap: 12px; + } + + [data-slot="session-turn-meta"] [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } + + [data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"], + [data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] { + opacity: 1; + pointer-events: auto; + } + + [data-slot="session-turn-meta-label"] { + user-select: none; + min-width: 0; + overflow: clip; + white-space: nowrap; + text-overflow: ellipsis; } [data-component="text-reveal"].session-turn-thinking-heading { flex: 1 1 auto; min-width: 0; + overflow: clip; + white-space: nowrap; + line-height: inherit; color: var(--text-weaker); font-weight: var(--font-weight-regular); + + [data-slot="text-reveal-track"], + [data-slot="text-reveal-entering"], + [data-slot="text-reveal-leaving"] { + min-height: 0; + line-height: inherit; + } } .error-card { @@ -84,7 +180,7 @@ display: flex; flex-direction: column; align-self: stretch; - gap: 12px; + gap: 0px; > :first-child > [data-component="markdown"]:first-child { margin-top: 0; @@ -109,6 +205,7 @@ [data-component="session-turn-diffs-trigger"] { width: 100%; + height: 36px; display: flex; align-items: center; justify-content: flex-start; @@ -118,7 +215,7 @@ [data-slot="session-turn-diffs-title"] { display: inline-flex; - align-items: baseline; + align-items: center; gap: 8px; } @@ -135,7 +232,7 @@ font-family: var(--font-family-sans); font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - line-height: var(--line-height-x-large); + line-height: var(--line-height-large); } [data-slot="session-turn-diffs-meta"] { @@ -173,6 +270,9 @@ display: flex; flex-grow: 1; min-width: 0; + align-items: baseline; + overflow: clip; + white-space: nowrap; font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -180,9 +280,10 @@ } [data-slot="session-turn-diff-directory"] { - color: var(--text-base); - overflow: hidden; - text-overflow: ellipsis; + flex: 1 1 auto; + color: var(--text-weak); + min-width: 0; + overflow: clip; white-space: nowrap; direction: rtl; text-align: left; @@ -190,6 +291,10 @@ [data-slot="session-turn-diff-filename"] { flex-shrink: 0; + max-width: 100%; + min-width: 0; + overflow: clip; + white-space: nowrap; color: var(--text-strong); font-weight: var(--font-weight-medium); } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0aaf571e8c8..0b9aa31237b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,23 +3,27 @@ import type { SessionStatus } from "@kilocode/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" +import { same } from "@opencode-ai/util/array" import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" +import { GrowBox } from "./grow-box" +import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" +import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" +import { list } from "./text-utils" +import { SessionRetry } from "./session-retry" +import { Tooltip } from "./tooltip" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" - function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -73,18 +77,12 @@ function unwrap(message: string) { return message } -function same(a: readonly T[], b: readonly T[]) { - if (a === b) return true - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} - const hidden = new Set(["todowrite", "todoread"]) +const emptyMessages: MessageType[] = [] +const emptyAssistant: AssistantMessage[] = [] +const emptyDiffs: FileDiff[] = [] +const idle: SessionStatus = { type: "idle" as const } +const handoffHoldMs = 120 function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { @@ -141,6 +139,7 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean @@ -159,11 +158,7 @@ export function SessionTurn( const i18n = useI18n() const fileComponent = useFileComponent() - const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] - const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: FileDiff[] = [] - const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -191,42 +186,8 @@ export function SessionTurn( return msg }) - const pending = createMemo(() => { - if (typeof props.active === "boolean" && typeof props.queued === "boolean") return - const messages = allMessages() ?? emptyMessages - return messages.findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ) - }) - - const pendingUser = createMemo(() => { - const item = pending() - if (!item?.parentID) return - const messages = allMessages() ?? emptyMessages - const result = Binary.search(messages, item.parentID, (m) => m.id) - const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) - if (!msg || msg.role !== "user") return - return msg - }) - - const active = createMemo(() => { - if (typeof props.active === "boolean") return props.active - const msg = message() - const parent = pendingUser() - if (!msg || !parent) return false - return parent.id === msg.id - }) - - const queued = createMemo(() => { - if (typeof props.queued === "boolean") return props.queued - const id = message()?.id - if (!id) return false - if (!pendingUser()) return false - const item = pending() - if (!item) return false - return id > item.id - }) - + const active = createMemo(() => props.active ?? false) + const queued = createMemo(() => props.queued ?? false) const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts @@ -289,7 +250,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const showAssistantCopyPartID = createMemo(() => { + const assistantCopyPart = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -299,13 +260,18 @@ export function SessionTurn( const parts = list(data.store.part?.[message.id], emptyParts) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] - if (!part || part.type !== "text" || !part.text?.trim()) continue - return part.id + if (!part || part.type !== "text") continue + const text = part.text?.trim() + if (!text) continue + return { + id: part.id, + text, + message, + } } } - - return undefined }) + const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -313,18 +279,14 @@ export function SessionTurn( return unwrap(String(msg)) }) - const status = createMemo(() => { - if (props.status !== undefined) return props.status - if (typeof props.active === "boolean" && !props.active) return idle - return data.store.session_status[props.sessionID] ?? idle + const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) + const working = createMemo(() => { + if (status().type === "idle") return false + if (!message()) return false + return active() }) - const working = createMemo(() => status().type !== "idle" && active()) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - - const assistantCopyPartID = createMemo(() => { - if (working()) return null - return showAssistantCopyPartID() ?? null - }) + const showDiffSummary = createMemo(() => edited() > 0 && !working()) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -364,13 +326,109 @@ export function SessionTurn( .filter((text): text is string => !!text) .at(-1), ) - const showThinking = createMemo(() => { + const thinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) + const hasAssistant = createMemo(() => assistantMessages().length > 0) + const animateEnabled = createMemo(() => props.animate !== false) + const [live, setLive] = createSignal(false) + const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) + const metaOpen = createMemo(() => !working() && !!assistantCopyPart()) + const duration = createMemo(() => { + const ms = turnDurationMs() + if (typeof ms !== "number" || ms < 0) return "" + + const total = Math.round(ms / 1000) + if (total < 60) return `${total}s` + + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return `${minutes}m ${seconds}s` + }) + const meta = createMemo(() => { + const item = assistantCopyPart() + if (!item) return "" + + const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : "" + const model = item.message.modelID + ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[ + item.message.modelID + ]?.name ?? item.message.modelID) + : "" + return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0") + }) + const [copied, setCopied] = createSignal(false) + const [handoffHold, setHandoffHold] = createSignal(false) + const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold()) + const handoffOpen = createMemo(() => thinkingVisible() || metaOpen()) + const lane = createMemo(() => hasAssistant() || handoffOpen()) + + let liveFrame: number | undefined + let copiedTimer: ReturnType | undefined + let handoffTimer: ReturnType | undefined + + const copyAssistant = async () => { + const text = assistantCopyPart()?.text + if (!text) return + + await navigator.clipboard.writeText(text) + setCopied(true) + if (copiedTimer !== undefined) clearTimeout(copiedTimer) + copiedTimer = setTimeout(() => { + copiedTimer = undefined + setCopied(false) + }, 2000) + } + + createEffect( + on( + () => [animateEnabled(), working()] as const, + ([enabled, isWorking]) => { + if (liveFrame !== undefined) { + cancelAnimationFrame(liveFrame) + liveFrame = undefined + } + if (!enabled || !isWorking || live()) return + liveFrame = requestAnimationFrame(() => { + liveFrame = undefined + setLive(true) + }) + }, + ), + ) + + createEffect( + on( + () => [thinkingOpen(), metaOpen()] as const, + ([thinkingNow, metaNow]) => { + if (handoffTimer !== undefined) { + clearTimeout(handoffTimer) + handoffTimer = undefined + } + + if (thinkingNow) { + setHandoffHold(true) + return + } + + if (metaNow) { + setHandoffHold(false) + return + } + + if (!handoffHold()) return + handoffTimer = setTimeout(() => { + handoffTimer = undefined + setHandoffHold(false) + }, handoffHoldMs) + }, + { defer: true }, + ), + ) const autoScroll = createAutoScroll({ working, @@ -378,6 +436,119 @@ export function SessionTurn( overflowAnchor: "dynamic", }) + onCleanup(() => { + if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) + if (copiedTimer !== undefined) clearTimeout(copiedTimer) + if (handoffTimer !== undefined) clearTimeout(handoffTimer) + }) + + const turnDiffSummary = () => ( +
+ + +
+
+ {i18n.t("ui.sessionReview.change.modified")} + + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + +
+ + +
+
+
+
+ + +
+ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(diff) => { + const active = createMemo(() => expanded().includes(diff.file)) + const [visible, setVisible] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }, + { defer: true }, + ), + ) + + return ( + + + +
+ + + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + +
+ + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
+
+
+
+
+
+ ) + + const divider = (label: string) => ( +
+
+ + + {label} + + +
+
+ ) + return (
- +
{(part) => ( -
- -
+ +
+ +
+
)}
- 0}> -
- -
-
- -
- - - + +
+ - -
-
- - 0 && !working()}> -
- - -
-
- - {i18n.t("ui.sessionReview.change.modified")} - - - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
- - -
-
-
-
- - -
- setExpanded(Array.isArray(value) ? value : value ? [value] : [])} +
+
+ +
+
+ + +
+ +
+ + event.preventDefault()} + onClick={() => void copyAssistant()} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + - - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - - - -
- - - - {`\u202A${getDirectory(diff.file)}\u202C`} - - - - {getFilename(diff.file)} - - -
- - - - - - -
-
-
-
- - -
- -
-
-
-
- ) - }} -
- -
-
- - -
- + {meta()} + + +
+
+
+ +
+ + {divider(i18n.t("ui.message.interrupted"))} + + + + {turnDiffSummary()} + {errorText()} diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx new file mode 100644 index 00000000000..0b92ef48b65 --- /dev/null +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -0,0 +1,310 @@ +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import stripAnsi from "strip-ansi" +import type { ToolPart } from "@kilocode/sdk/v2" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import { useI18n } from "../context/i18n" +import { RollingResults } from "./rolling-results" +import { Icon } from "./icon" +import { IconButton } from "./icon-button" +import { TextShimmer } from "./text-shimmer" +import { Tooltip } from "./tooltip" +import { GROW_SPRING } from "./motion" +import { useSpring } from "./motion-spring" +import { + busy, + createThrottledValue, + hold, + updateScrollMask, + useCollapsible, + useRowWipe, + useToolFade, +} from "./tool-utils" + +function ShellRollingSubtitle(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function firstLine(text: string) { + return text + .split(/\r\n|\n|\r/g) + .map((item) => item.trim()) + .find((item) => item.length > 0) +} + +function shellRows(output: string) { + const rows: { id: string; text: string }[] = [] + const lines = output + .split(/\r\n|\n|\r/g) + .map((item) => item.trimEnd()) + .filter((item) => item.length > 0) + const start = Math.max(0, lines.length - 80) + for (let i = start; i < lines.length; i++) { + rows.push({ id: `line:${i}`, text: lines[i]! }) + } + + return rows +} + +function ShellRollingCommand(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( +
+ + $ {props.text} + +
+ ) +} + +function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { + const i18n = useI18n() + const rows = 10 + const rowHeight = 22 + const max = rows * rowHeight + + let contentRef: HTMLDivElement | undefined + let bodyRef: HTMLDivElement | undefined + let scrollRef: HTMLDivElement | undefined + let topRef: HTMLDivElement | undefined + const [copied, setCopied] = createSignal(false) + const [cap, setCap] = createSignal(max) + + const updateMask = () => { + if (scrollRef) updateScrollMask(scrollRef) + } + + const resize = () => { + const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0) + setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0))) + } + + const measure = () => { + resize() + return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0) + } + + onMount(() => { + resize() + if (!topRef) return + const obs = new ResizeObserver(resize) + obs.observe(topRef) + onCleanup(() => obs.disconnect()) + }) + + createEffect(() => { + props.cmd + props.out + queueMicrotask(() => { + resize() + updateMask() + }) + }) + + useCollapsible({ + content: () => contentRef, + body: () => bodyRef, + open: () => props.open, + measure, + onOpen: updateMask, + }) + + const handleCopy = async (e: MouseEvent) => { + e.stopPropagation() + const cmd = props.cmd ? `$ ${props.cmd}` : "" + const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}` + if (!text) return + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+
+
+
+ $ + {props.cmd} +
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+ + <> +
+
+
+                  {props.out}
+                
+
+ + +
+
+
+ ) +} + +export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { + const i18n = useI18n() + const wiped = new Set() + const [mounted, setMounted] = createSignal(false) + const [userToggled, setUserToggled] = createSignal(false) + const [userOpen, setUserOpen] = createSignal(false) + onMount(() => setMounted(true)) + const state = createMemo(() => props.part.state as Record) + const pending = createMemo(() => busy(props.part.state.status)) + const autoOpen = hold(pending, 2000) + const effectiveOpen = createMemo(() => { + if (pending()) return true + if (userToggled()) return userOpen() + return autoOpen() + }) + const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen()) + const previewOpen = createMemo(() => effectiveOpen() && !expanded()) + const command = createMemo(() => { + const value = state().input?.command ?? state().metadata?.command + if (typeof value === "string") return value + return "" + }) + const subtitle = createMemo(() => { + const value = state().input?.description ?? state().metadata?.description + if (typeof value === "string" && value.trim().length > 0) return value + return firstLine(command()) ?? "" + }) + const output = createMemo(() => { + const value = state().output ?? state().metadata?.output + if (typeof value === "string") return value + return "" + }) + const reduce = prefersReducedMotion + const skip = () => reduce() || props.animate === false + const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING) + const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING) + const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING) + const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING) + const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING) + let headerClipRef: HTMLDivElement | undefined + const handleHeaderClick = () => { + if (pending()) return + const el = headerClipRef + const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null + const beforeY = el?.getBoundingClientRect().top ?? 0 + setUserToggled(true) + setUserOpen((prev) => !prev) + if (viewport && el) { + requestAnimationFrame(() => { + const afterY = el.getBoundingClientRect().top + const delta = afterY - beforeY + if (delta !== 0) viewport.scrollTop += delta + }) + } + } + const line = createMemo(() => firstLine(command())) + const fixed = createMemo(() => { + const value = line() + if (!value) return + return + }) + const text = createThrottledValue(() => stripAnsi(output())) + const rows = createMemo(() => shellRows(text())) + + return ( +
+
+
+ + + + {(text) => } + + + + + + + +
+
+
+ row.id} + render={(row) => { + const [textRef, setTextRef] = createSignal() + useRowWipe({ + id: () => row.id, + text: () => row.text, + ref: textRef, + seen: wiped, + }) + return ( +
+ + {row.text} + +
+ ) + }} + /> +
+ +
+ ) +} diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css index f72ba3fc755..9f19c2d1527 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -1,23 +1,13 @@ [data-component="shell-submessage"] { min-width: 0; max-width: 100%; - display: inline-flex; - align-items: baseline; + display: inline-block; vertical-align: baseline; } -[data-component="shell-submessage"] [data-slot="shell-submessage-width"] { - min-width: 0; - max-width: 100%; - display: inline-flex; - align-items: baseline; - overflow: hidden; -} - [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { display: inline-block; vertical-align: baseline; min-width: 0; - line-height: inherit; white-space: nowrap; } diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index fc99804d8de..9fe18fac4d7 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -146,7 +146,7 @@ --tabs-review-fade: 16px; gap: var(--tabs-review-gap); background-color: var(--background-stronger); - border-bottom: 1px solid var(--border-weak-base); + border-bottom: 1px solid var(--border-weaker-base); &::after { display: none; @@ -407,11 +407,7 @@ align-items: center; background-color: var(--background-stronger); box-sizing: border-box; - border-bottom: 1px solid transparent; - - &[data-scrolled] { - border-bottom-color: var(--border-weak-base); - } + border-bottom: 1px solid var(--border-weak-base); } [data-slot="tabs-trigger-wrapper"] { diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index f799962f094..7939322e6d4 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -4,14 +4,14 @@ * Instead of sliding text through a fixed mask (odometer style), * the mask itself sweeps across each span to reveal/hide text. * - * Direction: top-to-bottom. New text drops in from above, old text exits downward. + * Direction: bottom-to-top. New text rises in from below, old text exits upward. * - * Entering: gradient reveals top-to-bottom (top of text appears first). + * Entering: gradient reveals bottom-to-top (bottom of text appears first). * gradient(to bottom, white 33%, transparent 33%+edge) * pos 0 100% = transparent covers element = hidden * pos 0 0% = white covers element = visible * - * Leaving: gradient hides top-to-bottom (top of text disappears first). + * Leaving: gradient hides bottom-to-top (bottom of text disappears first). * gradient(to top, white 33%, transparent 33%+edge) * pos 0 100% = white covers element = visible * pos 0 0% = transparent covers element = hidden @@ -56,17 +56,17 @@ transition-timing-function: var(--_spring); } - /* ── entering: reveal top-to-bottom ── - * Gradient(to top): white at bottom, transparent at top of mask. - * Settled pos 0 100% = white covers element = visible - * Swap pos 0 0% = transparent covers = hidden - * Slides from above: translateY(-travel) → translateY(0) + /* ── entering: reveal bottom-to-top ── + * Gradient(to bottom): white at top, transparent at bottom of mask. + * Settled pos 0 0% = white covers element = visible + * Swap pos 0 100% = transparent covers = hidden + * Rises from below: translateY(travel) → translateY(0) */ [data-slot="text-reveal-entering"] { - mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); - -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); - mask-position: 0 100%; - -webkit-mask-position: 0 100%; + mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); + -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); + mask-position: 0 0%; + -webkit-mask-position: 0 0%; transition-property: mask-position, -webkit-mask-position, @@ -74,37 +74,37 @@ transform: translateY(0); } - /* ── leaving: hide top-to-bottom + slide downward ── - * Gradient(to bottom): white at top, transparent at bottom of mask. - * Swap pos 0 0% = white covers element = visible - * Settled pos 0 100% = transparent covers = hidden - * Slides down: translateY(0) → translateY(travel) + /* ── leaving: hide bottom-to-top + slide upward ── + * Gradient(to top): white at bottom, transparent at top of mask. + * Swap pos 0 100% = white covers element = visible + * Settled pos 0 0% = transparent covers = hidden + * Slides up: translateY(0) → translateY(-travel) */ [data-slot="text-reveal-leaving"] { - mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); - -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); - mask-position: 0 100%; - -webkit-mask-position: 0 100%; + mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); + -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); + mask-position: 0 0%; + -webkit-mask-position: 0 0%; transition-property: mask-position, -webkit-mask-position, transform; - transform: translateY(var(--_travel)); + transform: translateY(calc(var(--_travel) * -1)); } /* ── swapping: instant reset ── - * Snap entering to hidden (above), leaving to visible (center). + * Snap entering to hidden (below), leaving to visible (center). */ &[data-swapping="true"] [data-slot="text-reveal-entering"] { - mask-position: 0 0%; - -webkit-mask-position: 0 0%; - transform: translateY(calc(var(--_travel) * -1)); + mask-position: 0 100%; + -webkit-mask-position: 0 100%; + transform: translateY(var(--_travel)); transition-duration: 0ms !important; } &[data-swapping="true"] [data-slot="text-reveal-leaving"] { - mask-position: 0 0%; - -webkit-mask-position: 0 0%; + mask-position: 0 100%; + -webkit-mask-position: 0 100%; transform: translateY(0); transition-duration: 0ms !important; } @@ -126,15 +126,14 @@ &[data-truncate="true"] [data-slot="text-reveal-track"] { width: 100%; min-width: 0; - overflow: hidden; + overflow: clip; } &[data-truncate="true"] [data-slot="text-reveal-entering"], &[data-truncate="true"] [data-slot="text-reveal-leaving"] { min-width: 0; width: 100%; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; } } diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index c4fe1302f0e..7ddf4a50b89 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,4 +1,13 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + GROW_SPRING, + WIPE_MASK, +} from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" const px = (value: number | string | undefined, fallback: number) => { if (typeof value === "number") return `${value}px` @@ -17,6 +26,11 @@ const pct = (value: number | undefined, fallback: number) => { return `${v}%` } +const clearWipe = (el: HTMLElement) => { + clearFadeStyles(el) + clearMaskStyles(el) +} + export function TextReveal(props: { text?: string class?: string @@ -39,10 +53,8 @@ export function TextReveal(props: { let outRef: HTMLSpanElement | undefined let rootRef: HTMLSpanElement | undefined let frame: number | undefined - const win = () => inRef?.scrollWidth ?? 0 const wout = () => outRef?.scrollWidth ?? 0 - const widen = (next: number) => { if (next <= 0) return if (props.growOnly ?? true) { @@ -51,21 +63,14 @@ export function TextReveal(props: { } setWidth(`${next}px`) } - createEffect( on( () => props.text, (next, prev) => { if (next === prev) return - if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) { - setCur(next) - widen(win()) - return - } setSwapping(true) setOld(prev) setCur(next) - if (typeof requestAnimationFrame !== "function") { widen(Math.max(win(), wout())) rootRef?.offsetHeight @@ -133,3 +138,94 @@ export function TextReveal(props: { ) } + +export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + let frame: number | undefined + let anim: AnimationPlaybackControls | undefined + + const run = () => { + if (props.animate === false) return + const el = ref + if (!el || !props.text || typeof window === "undefined") return + if (prefersReducedMotion()) return + + const mask = + typeof CSS !== "undefined" && + (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") || + CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)")) + + anim?.stop() + if (frame !== undefined && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(frame) + frame = undefined + } + + el.style.opacity = "0" + el.style.filter = "blur(3px)" + el.style.transform = "translateX(-0.06em)" + + if (mask) { + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + } + + if (typeof requestAnimationFrame !== "function") { + clearWipe(el) + return + } + + frame = requestAnimationFrame(() => { + frame = undefined + const node = ref + if (!node) return + anim = mask + ? animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, + { ...GROW_SPRING, delay: props.delay ?? 0 }, + ) + : animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, + { ...GROW_SPRING, delay: props.delay ?? 0 }, + ) + + anim?.finished.then(() => { + const value = ref + if (!value) return + clearWipe(value) + }) + }) + } + + createEffect( + on( + () => [props.text, props.animate] as const, + ([text, enabled]) => { + if (!text || enabled === false) { + if (ref) clearWipe(ref) + return + } + run() + }, + ), + ) + + onCleanup(() => { + if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame) + anim?.stop() + }) + + return ( + + {props.text ?? "\u00A0"} + + ) +} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index f042dd2d862..bd1437c273b 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -1,11 +1,11 @@ [data-component="text-shimmer"] { --text-shimmer-step: 45ms; - --text-shimmer-duration: 1200ms; + --text-shimmer-duration: 2000ms; --text-shimmer-swap: 220ms; --text-shimmer-index: 0; --text-shimmer-angle: 90deg; --text-shimmer-spread: 5.2ch; - --text-shimmer-size: 360%; + --text-shimmer-size: 600%; --text-shimmer-base-color: var(--text-weak); --text-shimmer-peak-color: var(--text-strong); --text-shimmer-sweep: linear-gradient( @@ -16,15 +16,17 @@ ); --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color)); - display: inline-flex; - align-items: baseline; + display: inline-block; + vertical-align: baseline; font: inherit; letter-spacing: inherit; line-height: inherit; } [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { - display: inline-grid; + display: inline-block; + position: relative; + vertical-align: baseline; white-space: pre; font: inherit; letter-spacing: inherit; @@ -33,7 +35,7 @@ [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - grid-area: 1 / 1; + display: inline-block; white-space: pre; transition: opacity var(--text-shimmer-swap) ease-out; font: inherit; @@ -42,11 +44,14 @@ } [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { + position: relative; color: inherit; opacity: 1; } [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + position: absolute; + inset: 0; color: var(--text-weaker); opacity: 0; } diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index c4c20b8e768..0d797e5c1f8 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -8,6 +8,7 @@ export const TextShimmer = (props: { active?: boolean offset?: number }) => { + const text = createMemo(() => props.text ?? "") const active = createMemo(() => props.active ?? true) const offset = createMemo(() => props.offset ?? 0) const [run, setRun] = createSignal(active()) @@ -36,24 +37,36 @@ export const TextShimmer = (props: { clearTimeout(timer) }) + const len = createMemo(() => Math.max(text().length, 1)) + const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len()))) + + // duration = len × (size - 1) / velocity → uniform perceived sweep speed + const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline + const shimmerDuration = createMemo(() => { + const s = shimmerSize() / 100 + return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY))) + }) + return ( diff --git a/packages/ui/src/components/text-utils.ts b/packages/ui/src/components/text-utils.ts new file mode 100644 index 00000000000..c094b5e65f6 --- /dev/null +++ b/packages/ui/src/components/text-utils.ts @@ -0,0 +1,17 @@ +/** Find the longest common character prefix between two strings. */ +export function commonPrefix(a: string, b: string) { + const ac = Array.from(a) + const bc = Array.from(b) + let i = 0 + while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++ + return { + prefix: ac.slice(0, i).join(""), + aSuffix: ac.slice(i).join(""), + bSuffix: bc.slice(i).join(""), + } +} + +export function list(value: T[] | undefined | null, fallback: T[]): T[] { + if (Array.isArray(value)) return value + return fallback +} diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css index 11a33ff5d14..4ed46e50b5f 100644 --- a/packages/ui/src/components/tool-count-label.css +++ b/packages/ui/src/components/tool-count-label.css @@ -27,10 +27,10 @@ grid-template-columns: 0fr; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42)); - overflow: hidden; + overflow: clip; transform: translateX(-0.04em); transition-property: grid-template-columns, opacity, filter, transform; - transition-duration: 250ms, 250ms, 250ms, 250ms; + transition-duration: 800ms, 400ms, 400ms, 800ms; transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -45,7 +45,7 @@ [data-slot="tool-count-label-suffix-inner"] { min-width: 0; - overflow: hidden; + overflow: clip; white-space: pre; } } diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx index 67e861cdcb3..c374d2d3762 100644 --- a/packages/ui/src/components/tool-count-label.tsx +++ b/packages/ui/src/components/tool-count-label.tsx @@ -1,5 +1,6 @@ import { createMemo } from "solid-js" import { AnimatedNumber } from "./animated-number" +import { commonPrefix } from "./text-utils" function split(text: string) { const match = /{{\s*count\s*}}/.exec(text) @@ -11,35 +12,23 @@ function split(text: string) { } } -function common(one: string, other: string) { - const a = Array.from(one) - const b = Array.from(other) - let i = 0 - while (i < a.length && i < b.length && a[i] === b[i]) i++ - return { - stem: a.slice(0, i).join(""), - one: a.slice(i).join(""), - other: b.slice(i).join(""), - } -} - export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) { const one = createMemo(() => split(props.one)) const other = createMemo(() => split(props.other)) const singular = createMemo(() => Math.round(props.count) === 1) const active = createMemo(() => (singular() ? one() : other())) - const suffix = createMemo(() => common(one().after, other().after)) + const suffix = createMemo(() => commonPrefix(one().after, other().after)) const splitSuffix = createMemo( () => one().before === other().before && (one().after.startsWith(other().after) || other().after.startsWith(one().after)), ) const before = createMemo(() => (splitSuffix() ? one().before : active().before)) - const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) + const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after)) const tail = createMemo(() => { if (!splitSuffix()) return "" - if (singular()) return suffix().one - return suffix().other + if (singular()) return suffix().aSuffix + return suffix().bSuffix }) const showTail = createMemo(() => splitSuffix() && tail().length > 0) diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css index da8455267cc..435ed95fe60 100644 --- a/packages/ui/src/components/tool-count-summary.css +++ b/packages/ui/src/components/tool-count-summary.css @@ -10,12 +10,12 @@ opacity: 1; filter: blur(0); transform: translateY(0) scale(1); - overflow: hidden; + overflow: clip; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms), - var(--tool-motion-spring-ms, 480ms); + var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), + var(--tool-motion-spring-ms, 800ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -35,12 +35,12 @@ opacity: 0; filter: blur(var(--tool-motion-blur, 2px)); transform: translateY(0.06em) scale(0.985); - overflow: hidden; + overflow: clip; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms), - var(--tool-motion-spring-ms, 480ms); + var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), + var(--tool-motion-spring-ms, 800ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -55,7 +55,7 @@ [data-slot="tool-count-summary-empty-inner"] { min-width: 0; - overflow: hidden; + overflow: clip; white-space: nowrap; } @@ -63,7 +63,7 @@ display: inline-flex; align-items: baseline; min-width: 0; - overflow: hidden; + overflow: clip; white-space: nowrap; } @@ -75,12 +75,11 @@ margin-right: 0; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55)); - overflow: hidden; + overflow: clip; transform: translateX(-0.08em); transition-property: opacity, filter, transform; transition-duration: - calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75), - calc(var(--tool-motion-fade-ms, 220ms) * 0.6); + var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms); transition-timing-function: ease-out, ease-out, ease-out; } diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index d4415bd2daf..050f5e390a6 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -18,9 +18,8 @@ [data-slot="tool-status-swap"], [data-slot="tool-status-tail"] { display: inline-grid; - overflow: hidden; + overflow: clip; justify-items: start; - transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="tool-status-active"], @@ -31,8 +30,8 @@ text-align: start; transition-property: opacity, filter, transform; transition-duration: - var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8), - calc(var(--tool-motion-fade-ms, 240ms) * 0.8); + var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8), + calc(var(--tool-motion-fade-ms, 400ms) * 0.8); transition-timing-function: ease-out, ease-out, ease-out; } diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 68440b6c637..0669f8cf263 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,17 +1,8 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" - -function common(active: string, done: string) { - const a = Array.from(active) - const b = Array.from(done) - let i = 0 - while (i < a.length && i < b.length && a[i] === b[i]) i++ - return { - prefix: a.slice(0, i).join(""), - active: a.slice(i).join(""), - done: b.slice(i).join(""), - } -} +import { commonPrefix } from "./text-utils" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -27,25 +18,59 @@ export function ToolStatusTitle(props: { class?: string split?: boolean }) { - const split = createMemo(() => common(props.activeText, props.doneText)) + const split = createMemo(() => commonPrefix(props.activeText, props.doneText)) const suffix = createMemo( - () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, + () => + (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0, ) const prefixLen = createMemo(() => Array.from(split().prefix).length) - const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) - const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText)) - const [width, setWidth] = createSignal("auto") const [ready, setReady] = createSignal(false) let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined + let swapRef: HTMLSpanElement | undefined + let tailRef: HTMLSpanElement | undefined let frame: number | undefined let readyFrame: number | undefined + let widthAnim: AnimationPlaybackControls | undefined + + const node = () => (suffix() ? tailRef : swapRef) + + const reduce = prefersReducedMotion + + const setNodeWidth = (width: string) => { + if (swapRef) swapRef.style.width = width + if (tailRef) tailRef.style.width = width + } const measure = () => { const target = props.active ? activeRef : doneRef - const px = contentWidth(target) - if (px > 0) setWidth(`${px}px`) + const next = contentWidth(target) + if (next <= 0) return + + const ref = node() + if (!ref || !ready() || reduce()) { + widthAnim?.stop() + setNodeWidth(`${next}px`) + return + } + + const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width)) + if (Math.abs(next - prev) < 1) { + ref.style.width = `${next}px` + return + } + + ref.style.width = `${prev}px` + widthAnim?.stop() + widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING) + widthAnim.finished.then(() => { + const el = node() + if (!el) return + el.style.width = `${next}px` + }) } const schedule = () => { @@ -90,6 +115,7 @@ export function ToolStatusTitle(props: { onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + widthAnim?.stop() }) return ( @@ -104,7 +130,7 @@ export function ToolStatusTitle(props: { + @@ -118,7 +144,7 @@ export function ToolStatusTitle(props: { - + diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts new file mode 100644 index 00000000000..c1e861e9459 --- /dev/null +++ b/packages/ui/src/components/tool-utils.ts @@ -0,0 +1,340 @@ +import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + COLLAPSIBLE_SPRING, + GROW_SPRING, + WIPE_MASK, +} from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import type { ToolPart } from "@kilocode/sdk/v2" + +export const TEXT_RENDER_THROTTLE_MS = 100 + +export function createThrottledValue(getValue: () => string) { + const [value, setValue] = createSignal(getValue()) + let timeout: ReturnType | undefined + let last = 0 + + createEffect(() => { + const next = getValue() + const now = Date.now() + + const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) + if (remaining <= 0) { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + last = now + setValue(next) + return + } + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + last = Date.now() + setValue(next) + timeout = undefined + }, remaining) + }) + + onCleanup(() => { + if (timeout) clearTimeout(timeout) + }) + + return value +} + +export function busy(status: string | undefined) { + return status === "pending" || status === "running" +} + +export function hold(state: () => boolean, wait = 2000) { + const [live, setLive] = createSignal(state()) + let timer: ReturnType | undefined + + createEffect(() => { + if (state()) { + if (timer) clearTimeout(timer) + timer = undefined + setLive(true) + return + } + + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = undefined + setLive(false) + }, wait) + }) + + onCleanup(() => { + if (timer) clearTimeout(timer) + }) + + return live +} + +export function updateScrollMask(el: HTMLElement, fade = 12) { + const { scrollTop, scrollHeight, clientHeight } = el + const overflow = scrollHeight - clientHeight + if (overflow <= 1) { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + return + } + const top = scrollTop > 1 + const bottom = scrollTop < overflow - 1 + const mask = + top && bottom + ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)` + : top + ? `linear-gradient(to bottom, transparent 0, black ${fade}px)` + : bottom + ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)` + : "" + el.style.maskImage = mask + el.style.webkitMaskImage = mask +} + +export function useCollapsible(options: { + content: () => HTMLElement | undefined + body: () => HTMLElement | undefined + open: () => boolean + measure?: () => number + onOpen?: () => void +}) { + let heightAnim: AnimationPlaybackControls | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let gen = 0 + + createEffect( + on( + options.open, + (isOpen) => { + const content = options.content() + const body = options.body() + if (!content || !body) return + heightAnim?.stop() + fadeAnim?.stop() + const id = ++gen + if (prefersReducedMotion()) { + if (isOpen) { + content.style.display = "" + content.style.height = "auto" + body.style.opacity = "1" + body.style.filter = "" + options.onOpen?.() + } else { + content.style.height = "0px" + body.style.opacity = "0" + body.style.filter = "blur(2px)" + content.style.display = "none" + } + return + } + if (isOpen) { + content.style.display = "" + content.style.height = "0px" + body.style.opacity = "0" + body.style.filter = "blur(2px)" + fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) + queueMicrotask(() => { + if (gen !== id) return + const c = options.content() + if (!c) return + const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height) + heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) + heightAnim.finished.then( + () => { + if (gen !== id) return + c.style.height = "auto" + options.onOpen?.() + }, + () => {}, + ) + }) + return + } + + const h = content.getBoundingClientRect().height + heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) + fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) + heightAnim.finished.then( + () => { + if (gen !== id) return + content.style.display = "none" + }, + () => {}, + ) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + ++gen + heightAnim?.stop() + fadeAnim?.stop() + }) +} + +export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) { + const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status))) + const [settled, setSettled] = createSignal(false) + createEffect(() => { + if (!anyRunning() && !working?.()) setSettled(true) + }) + return createMemo(() => !settled() && (!!working?.() || anyRunning())) +} + +export function useRowWipe(opts: { + id: () => string + text: () => string | undefined + ref: () => HTMLElement | undefined + seen: Set +}) { + const reduce = prefersReducedMotion + + createEffect(() => { + const id = opts.id() + const txt = opts.text() + const el = opts.ref() + if (!el) return + if (!txt) { + clearFadeStyles(el) + clearMaskStyles(el) + return + } + if (reduce() || typeof window === "undefined") { + clearFadeStyles(el) + clearMaskStyles(el) + return + } + if (opts.seen.has(id)) { + clearFadeStyles(el) + clearMaskStyles(el) + return + } + opts.seen.add(id) + + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + el.style.opacity = "0" + el.style.filter = "blur(2px)" + el.style.transform = "translateX(-0.06em)" + + let done = false + const clear = () => { + if (done) return + done = true + clearFadeStyles(el) + clearMaskStyles(el) + } + if (typeof requestAnimationFrame !== "function") { + clear() + return + } + let anim: AnimationPlaybackControls | undefined + let frame: number | undefined = requestAnimationFrame(() => { + frame = undefined + const node = opts.ref() + if (!node) return + anim = animate( + node, + { + opacity: [0, 1], + filter: ["blur(2px)", "blur(0px)"], + transform: ["translateX(-0.06em)", "translateX(0)"], + maskPosition: "0% 0%", + }, + GROW_SPRING, + ) + + anim.finished.catch(() => {}).finally(clear) + }) + + onCleanup(() => { + if (frame !== undefined) { + cancelAnimationFrame(frame) + clear() + } + }) + }) +} + +export function useToolFade( + ref: () => HTMLElement | undefined, + options?: { delay?: number; wipe?: boolean; animate?: boolean }, +) { + let anim: AnimationPlaybackControls | undefined + let frame: number | undefined + const delay = options?.delay ?? 0 + const wipe = options?.wipe ?? false + const active = options?.animate !== false + + onMount(() => { + if (!active) return + + const el = ref() + if (!el || typeof window === "undefined") return + if (prefersReducedMotion()) return + + const mask = + wipe && + typeof CSS !== "undefined" && + (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") || + CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)")) + + el.style.opacity = "0" + el.style.filter = wipe ? "blur(3px)" : "blur(2px)" + el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)" + + if (mask) { + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + } + + frame = requestAnimationFrame(() => { + frame = undefined + const node = ref() + if (!node) return + + anim = wipe + ? mask + ? animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, + { ...GROW_SPRING, delay }, + ) + : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay }) + : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay }) + + anim?.finished.then(() => { + const value = ref() + if (!value) return + clearFadeStyles(value) + if (mask) clearMaskStyles(value) + }) + }) + }) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + anim?.stop() + }) +} diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 3dc520c6213..d36102590b1 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -1,6 +1,8 @@ import { createEffect, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { animate, type AnimationPlaybackControls } from "motion" +import { FAST_SPRING } from "../components/motion" export interface AutoScrollOptions { working: () => boolean @@ -9,13 +11,28 @@ export interface AutoScrollOptions { bottomThreshold?: number } +const SETTLE_MS = 500 +const AUTO_SCROLL_GRACE_MS = 120 +const AUTO_SCROLL_EPSILON = 0.5 +const MANUAL_ANCHOR_MS = 3000 +const MANUAL_ANCHOR_QUIET_FRAMES = 24 + export function createAutoScroll(options: AutoScrollOptions) { let scroll: HTMLElement | undefined let settling = false let settleTimer: ReturnType | undefined - let autoTimer: ReturnType | undefined let cleanup: (() => void) | undefined - let auto: { top: number; time: number } | undefined + let programmaticUntil = 0 + let scrollAnim: AnimationPlaybackControls | undefined + let hold: + | { + el: HTMLElement + top: number + until: number + quiet: number + frame: number | undefined + } + | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) { const active = () => options.working() || settling const distanceFromBottom = (el: HTMLElement) => { - return el.scrollHeight - el.clientHeight - el.scrollTop + // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up + return Math.abs(el.scrollTop) } const canScroll = (el: HTMLElement) => { return el.scrollHeight - el.clientHeight > 1 } - // Browsers can dispatch scroll events asynchronously. If new content arrives - // between us calling `scrollTo()` and the subsequent `scroll` event firing, - // the handler can see a non-zero `distanceFromBottom` and incorrectly assume - // the user scrolled. - const markAuto = (el: HTMLElement) => { - auto = { - top: Math.max(0, el.scrollHeight - el.clientHeight), - time: Date.now(), - } - - if (autoTimer) clearTimeout(autoTimer) - autoTimer = setTimeout(() => { - auto = undefined - autoTimer = undefined - }, 1500) + const markProgrammatic = () => { + programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS } - const isAuto = (el: HTMLElement) => { - const a = auto - if (!a) return false + const clearHold = () => { + const next = hold + if (!next) return + if (next.frame !== undefined) cancelAnimationFrame(next.frame) + hold = undefined + } - if (Date.now() - a.time > 1500) { - auto = undefined + const tickHold = () => { + const next = hold + const el = scroll + if (!next || !el) return false + if (Date.now() > next.until) { + clearHold() + return false + } + if (!next.el.isConnected) { + clearHold() return false } - return Math.abs(el.scrollTop - a.top) < 2 - } - - const scrollToBottomNow = (behavior: ScrollBehavior) => { - const el = scroll - if (!el) return - markAuto(el) - if (behavior === "smooth") { - el.scrollTo({ top: el.scrollHeight, behavior }) - return + const current = next.el.getBoundingClientRect().top + if (!Number.isFinite(current)) { + clearHold() + return false } - // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. - el.scrollTop = el.scrollHeight + const delta = current - next.top + if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) { + next.quiet += 1 + if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) { + clearHold() + return false + } + return true + } + + next.quiet = 0 + if (!store.userScrolled) { + setStore("userScrolled", true) + options.onUserInteracted?.() + } + el.scrollTop += delta + markProgrammatic() + return true + } + + const scheduleHold = () => { + const next = hold + if (!next) return + if (next.frame !== undefined) return + + next.frame = requestAnimationFrame(() => { + const value = hold + if (!value) return + value.frame = undefined + if (!tickHold()) return + scheduleHold() + }) + } + + const preserve = (target: HTMLElement) => { + const el = scroll + if (!el) return + + if (!store.userScrolled) { + setStore("userScrolled", true) + options.onUserInteracted?.() + } + + const top = target.getBoundingClientRect().top + if (!Number.isFinite(top)) return + + clearHold() + hold = { + el: target, + top, + until: Date.now() + MANUAL_ANCHOR_MS, + quiet: 0, + frame: undefined, + } + scheduleHold() } const scrollToBottom = (force: boolean) => { if (!force && !active()) return + clearHold() + if (force && store.userScrolled) setStore("userScrolled", false) const el = scroll if (!el) return + if (scrollAnim) cancelSmooth() if (!force && store.userScrolled) return - const distance = distanceFromBottom(el) - if (distance < 2) { - markAuto(el) + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() return } - // For auto-following content we prefer immediate updates to avoid - // visible "catch up" animations while content is still settling. - scrollToBottomNow("auto") + el.scrollTop = 0 + markProgrammatic() } - const stop = () => { + const cancelSmooth = () => { + if (scrollAnim) { + scrollAnim.stop() + scrollAnim = undefined + } + } + + const smoothScrollToBottom = () => { + const el = scroll + if (!el) return + + cancelSmooth() + if (store.userScrolled) setStore("userScrolled", false) + + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() + return + } + + scrollAnim = animate(el.scrollTop, 0, { + ...FAST_SPRING, + onUpdate: (v) => { + markProgrammatic() + el.scrollTop = v + }, + onComplete: () => { + scrollAnim = undefined + markProgrammatic() + }, + }) + } + + const stop = (input?: { hold?: boolean }) => { + if (input?.hold !== false) clearHold() + const el = scroll if (!el) return if (!canScroll(el)) { @@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) { } if (store.userScrolled) return + markProgrammatic() setStore("userScrolled", true) options.onUserInteracted?.() } const handleWheel = (e: WheelEvent) => { + if (e.deltaY !== 0) clearHold() + + if (e.deltaY > 0) { + const el = scroll + if (!el) return + if (distanceFromBottom(el) >= threshold()) return + if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() + return + } + if (e.deltaY >= 0) return - // If the user is scrolling within a nested scrollable region (tool output, - // code block, etc), don't treat it as leaving the "follow bottom" mode. - // Those regions opt in via `data-scrollable`. + cancelSmooth() const el = scroll const target = e.target instanceof Element ? e.target : undefined const nested = target?.closest("[data-scrollable]") @@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) { const el = scroll if (!el) return + if (hold) { + if (Date.now() < programmaticUntil) return + clearHold() + } + if (!canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (distanceFromBottom(el) < threshold()) { + if (Date.now() < programmaticUntil) return if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } - // Ignore scroll events triggered by our own scrollToBottom calls. - if (!store.userScrolled && isAuto(el)) { - scrollToBottom(false) - return - } + if (!store.userScrolled && Date.now() < programmaticUntil) return - stop() + stop({ hold: false }) } const handleInteraction = () => { @@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) { } const updateOverflowAnchor = (el: HTMLElement) => { + if (hold) { + el.style.overflowAnchor = "none" + return + } + const mode = options.overflowAnchor ?? "dynamic" if (mode === "none") { @@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) { () => store.contentRef, () => { const el = scroll + if (hold) { + scheduleHold() + return + } if (el && !canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (!active()) return if (store.userScrolled) return - // ResizeObserver fires after layout, before paint. - // Keep the bottom locked in the same frame to avoid visible - // "jump up then catch up" artifacts while streaming content. scrollToBottom(false) }, ) @@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) { settling = true settleTimer = setTimeout(() => { settling = false - }, 300) + }, SETTLE_MS) }), ) createEffect(() => { - // Track `userScrolled` even before `scrollRef` is attached, so we can - // update overflow anchoring once the element exists. store.userScrolled const el = scroll if (!el) return @@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - if (autoTimer) clearTimeout(autoTimer) + clearHold() + cancelSmooth() if (cleanup) cleanup() }) @@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) { scroll = el - if (!el) return + if (!el) { + clearHold() + return + } + markProgrammatic() updateOverflowAnchor(el) el.addEventListener("wheel", handleWheel, { passive: true }) @@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) { contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, handleInteraction, + preserve, pause: stop, - resume: () => { - if (store.userScrolled) setStore("userScrolled", false) - scrollToBottom(true) - }, - scrollToBottom: () => scrollToBottom(false), forceScrollToBottom: () => scrollToBottom(true), + smoothScrollToBottom, + snapToBottom: () => { + const el = scroll + if (!el) return + if (store.userScrolled) setStore("userScrolled", false) + // With column-reverse, scrollTop=0 is at the bottom + el.scrollTop = 0 + markProgrammatic() + }, userScrolled: () => store.userScrolled, } } diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 1c90a2e493d..4a218024d6c 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,2 +1,5 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" +export * from "./use-element-height" +export * from "./use-reduced-motion" +export * from "./use-page-visible" diff --git a/packages/ui/src/hooks/use-element-height.ts b/packages/ui/src/hooks/use-element-height.ts new file mode 100644 index 00000000000..a9f06ec8b84 --- /dev/null +++ b/packages/ui/src/hooks/use-element-height.ts @@ -0,0 +1,25 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +/** + * Tracks an element's height via ResizeObserver. + * Returns a reactive signal that updates whenever the element resizes. + */ +export function useElementHeight( + ref: Accessor | (() => HTMLElement | undefined), + initial = 0, +): Accessor { + const [height, setHeight] = createSignal(initial) + + createEffect(() => { + const el = ref() + if (!el) return + setHeight(el.getBoundingClientRect().height) + const observer = new ResizeObserver(() => { + setHeight(el.getBoundingClientRect().height) + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return height +} diff --git a/packages/ui/src/hooks/use-page-visible.ts b/packages/ui/src/hooks/use-page-visible.ts new file mode 100644 index 00000000000..88788ef4a99 --- /dev/null +++ b/packages/ui/src/hooks/use-page-visible.ts @@ -0,0 +1,11 @@ +import { createSignal } from "solid-js" + +export const pageVisible = /* @__PURE__ */ (() => { + const [visible, setVisible] = createSignal(true) + if (typeof document !== "undefined") { + const sync = () => setVisible(document.visibilityState !== "hidden") + sync() + document.addEventListener("visibilitychange", sync) + } + return visible +})() diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts new file mode 100644 index 00000000000..7fa815bbd34 --- /dev/null +++ b/packages/ui/src/hooks/use-reduced-motion.ts @@ -0,0 +1,9 @@ +import { createSignal } from "solid-js" + +export const prefersReducedMotion = /* @__PURE__ */ (() => { + if (typeof window === "undefined") return () => false + const mql = window.matchMedia("(prefers-reduced-motion: reduce)") + const [reduced, setReduced] = createSignal(mql.matches) + mql.addEventListener("change", () => setReduced(mql.matches)) + return reduced +})() diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 85bf3e7e44c..ec49757aced 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "قراءة المهام", "ui.tool.questions": "أسئلة", "ui.tool.agent": "وكيل {{type}}", + "ui.tool.agent.default": "وكيل", "ui.common.file.one": "ملف", "ui.common.file.other": "ملفات", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 978934da6d7..76552e301de 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "Ler tarefas", "ui.tool.questions": "Perguntas", "ui.tool.agent": "Agente {{type}}", + "ui.tool.agent.default": "Agente", "ui.common.file.one": "arquivo", "ui.common.file.other": "arquivos", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 71a1367d59b..4570b6b5a09 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -106,6 +106,7 @@ export const dict = { "ui.tool.todos.read": "Čitanje liste zadataka", "ui.tool.questions": "Pitanja", "ui.tool.agent": "{{type}} agent", + "ui.tool.agent.default": "agent", "ui.common.file.one": "datoteka", "ui.common.file.other": "datoteke", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 1d8b6d84395..3aa9460c8e6 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Læs opgaver", "ui.tool.questions": "Spørgsmål", "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "fil", "ui.common.file.other": "filer", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 00f1e6079a6..3756b3a9d33 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -107,6 +107,7 @@ export const dict = { "ui.tool.todos.read": "Aufgaben lesen", "ui.tool.questions": "Fragen", "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "Datei", "ui.common.file.other": "Dateien", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 0ad98117b76..139b28a36ea 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -103,6 +103,7 @@ export const dict: Record = { "ui.tool.todos.read": "Read to-dos", "ui.tool.questions": "Questions", "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "file", "ui.common.file.other": "files", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 5df36d5d92b..25e8afff770 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "Leer tareas", "ui.tool.questions": "Preguntas", "ui.tool.agent": "Agente {{type}}", + "ui.tool.agent.default": "Agente", "ui.common.file.one": "archivo", "ui.common.file.other": "archivos", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index df53d9c5584..bb730a77d70 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "Lire les tâches", "ui.tool.questions": "Questions", "ui.tool.agent": "Agent {{type}}", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "fichier", "ui.common.file.other": "fichiers", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 90448a8562b..cf416dd2ea1 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Todo読み込み", "ui.tool.questions": "質問", "ui.tool.agent": "{{type}}エージェント", + "ui.tool.agent.default": "エージェント", "ui.common.file.one": "ファイル", "ui.common.file.other": "ファイル", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 2eb297f860e..ca7c780647c 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "할 일 읽기", "ui.tool.questions": "질문", "ui.tool.agent": "{{type}} 에이전트", + "ui.tool.agent.default": "에이전트", "ui.common.file.one": "파일", "ui.common.file.other": "파일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index dc56d918cc9..bf4d612970b 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -105,6 +105,7 @@ export const dict: Record = { "ui.tool.todos.read": "Les gjøremål", "ui.tool.questions": "Spørsmål", "ui.tool.agent": "{{type}}-agent", + "ui.tool.agent.default": "agent", "ui.common.file.one": "fil", "ui.common.file.other": "filer", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 9e34769bfa7..c65ef51abad 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Czytaj zadania", "ui.tool.questions": "Pytania", "ui.tool.agent": "Agent {{type}}", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "plik", "ui.common.file.other": "pliki", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 0a747a24319..b27040faa37 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Читать задачи", "ui.tool.questions": "Вопросы", "ui.tool.agent": "Агент {{type}}", + "ui.tool.agent.default": "Агент", "ui.common.file.one": "файл", "ui.common.file.other": "файлов", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 5d480880648..58c0436f3f1 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -103,6 +103,7 @@ export const dict = { "ui.tool.todos.read": "อ่านรายการงาน", "ui.tool.questions": "คำถาม", "ui.tool.agent": "เอเจนต์ {{type}}", + "ui.tool.agent.default": "เอเจนต์", "ui.common.file.one": "ไฟล์", "ui.common.file.other": "ไฟล์", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 8db027784ad..d5d5642341b 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -108,6 +108,7 @@ export const dict = { "ui.tool.todos.read": "Görevleri oku", "ui.tool.questions": "Sorular", "ui.tool.agent": "{{type}} Ajan", + "ui.tool.agent.default": "Ajan", "ui.common.file.one": "dosya", "ui.common.file.other": "dosya", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 7c3b1b1e38b..ba1327af99c 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -106,6 +106,7 @@ export const dict = { "ui.tool.todos.read": "读取待办", "ui.tool.questions": "问题", "ui.tool.agent": "{{type}} 智能体", + "ui.tool.agent.default": "智能体", "ui.common.file.one": "个文件", "ui.common.file.other": "个文件", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 6a5637d8a30..ed1397b1a94 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -106,6 +106,7 @@ export const dict = { "ui.tool.todos.read": "讀取待辦", "ui.tool.questions": "問題", "ui.tool.agent": "{{type}} 代理程式", + "ui.tool.agent.default": "代理程式", "ui.common.file.one": "個檔案", "ui.common.file.other": "個檔案", diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cec42f5a0ca..213a37c5144 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,6 +40,7 @@ @import "../components/progress-circle.css" layer(components); @import "../components/radio-group.css" layer(components); @import "../components/resize-handle.css" layer(components); +@import "../components/rolling-results.css" layer(components); @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); diff --git a/packages/ui/src/styles/tailwind/colors.css b/packages/ui/src/styles/tailwind/colors.css index 376cd35d322..e43f199ea51 100644 --- a/packages/ui/src/styles/tailwind/colors.css +++ b/packages/ui/src/styles/tailwind/colors.css @@ -1,4 +1,4 @@ -/* Generated by script/colors.ts */ +/* Generated by script/tailwind.ts */ /* Do not edit this file manually */ @theme { @@ -77,10 +77,6 @@ --color-text-weaker: var(--text-weaker); --color-text-strong: var(--text-strong); --color-text-interactive-base: var(--text-interactive-base); - --color-text-invert-base: var(--text-invert-base); - --color-text-invert-weak: var(--text-invert-weak); - --color-text-invert-weaker: var(--text-invert-weaker); - --color-text-invert-strong: var(--text-invert-strong); --color-text-on-brand-base: var(--text-on-brand-base); --color-text-on-interactive-base: var(--text-on-interactive-base); --color-text-on-interactive-weak: var(--text-on-interactive-weak); @@ -123,6 +119,7 @@ --color-border-weak-selected: var(--border-weak-selected); --color-border-weak-disabled: var(--border-weak-disabled); --color-border-weak-focus: var(--border-weak-focus); + --color-border-weaker-base: var(--border-weaker-base); --color-border-interactive-base: var(--border-interactive-base); --color-border-interactive-hover: var(--border-interactive-hover); --color-border-interactive-active: var(--border-interactive-active); @@ -233,12 +230,6 @@ --color-markdown-image-text: var(--markdown-image-text); --color-markdown-code-block: var(--markdown-code-block); --color-border-color: var(--border-color); - --color-border-weaker-base: var(--border-weaker-base); - --color-border-weaker-hover: var(--border-weaker-hover); - --color-border-weaker-active: var(--border-weaker-active); - --color-border-weaker-selected: var(--border-weaker-selected); - --color-border-weaker-disabled: var(--border-weaker-disabled); - --color-border-weaker-focus: var(--border-weaker-focus); --color-button-ghost-hover: var(--button-ghost-hover); --color-button-ghost-hover2: var(--button-ghost-hover2); } diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 832b43ec74f..2637b4a2819 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -85,6 +85,10 @@ 0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03), 0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05), 0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09); + --shadow-sidebar-overlay: + 0 100px 80px 0 rgba(0, 0, 0, 0.29), 0 41.778px 33.422px 0 rgba(0, 0, 0, 0.21), + 0 22.336px 17.869px 0 rgba(0, 0, 0, 0.17), 0 12.522px 10.017px 0 rgba(0, 0, 0, 0.14), + 0 6.65px 5.32px 0 rgba(0, 0, 0, 0.12), 0 2.767px 2.214px 0 rgba(0, 0, 0, 0.08); color-scheme: light; --text-mix-blend-mode: multiply; @@ -212,6 +216,7 @@ --border-weak-selected: var(--cobalt-light-alpha-5); --border-weak-disabled: var(--smoke-light-alpha-6); --border-weak-focus: var(--smoke-light-alpha-7); + --border-weaker-base: var(--smoke-light-alpha-3); --border-interactive-base: var(--cobalt-light-7); --border-interactive-hover: var(--cobalt-light-8); --border-interactive-active: var(--cobalt-light-9); @@ -323,12 +328,6 @@ --markdown-image-text: #318795; --markdown-code-block: #1a1a1a; --border-color: #ffffff; - --border-weaker-base: var(--smoke-light-alpha-3); - --border-weaker-hover: var(--smoke-light-alpha-4); - --border-weaker-active: var(--smoke-light-alpha-6); - --border-weaker-selected: var(--cobalt-light-alpha-4); - --border-weaker-disabled: var(--smoke-light-alpha-2); - --border-weaker-focus: var(--smoke-light-alpha-6); --button-ghost-hover: var(--smoke-light-alpha-2); --button-ghost-hover2: var(--smoke-light-alpha-3); --avatar-background-pink: #feeef8; @@ -582,12 +581,7 @@ --markdown-image-text: #56b6c2; --markdown-code-block: #eeeeee; --border-color: #ffffff; - --border-weaker-base: var(--smoke-dark-alpha-3); - --border-weaker-hover: var(--smoke-dark-alpha-4); - --border-weaker-active: var(--smoke-dark-alpha-6); - --border-weaker-selected: var(--cobalt-dark-alpha-3); - --border-weaker-disabled: var(--smoke-dark-alpha-2); - --border-weaker-focus: var(--smoke-dark-alpha-6); + --border-weaker-base: var(--smoke-dark-alpha-2); --button-ghost-hover: var(--smoke-dark-alpha-2); --button-ghost-hover2: var(--smoke-dark-alpha-3); --avatar-background-pink: #501b3f; diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index f098e8028a3..e8fca41036e 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -152,11 +152,6 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["border-weak-disabled"] = neutralAlpha[5] tokens["border-weak-focus"] = neutralAlpha[isDark ? 7 : 6] tokens["border-weaker-base"] = neutralAlpha[2] - tokens["border-weaker-hover"] = neutralAlpha[3] - tokens["border-weaker-active"] = neutralAlpha[5] - tokens["border-weaker-selected"] = withAlpha(interactive[3], isDark ? 0.3 : 0.4) as ColorValue - tokens["border-weaker-disabled"] = neutralAlpha[1] - tokens["border-weaker-focus"] = neutralAlpha[5] tokens["border-interactive-base"] = interactive[6] tokens["border-interactive-hover"] = interactive[7] diff --git a/packages/ui/src/theme/themes/oc-1.json b/packages/ui/src/theme/themes/oc-1.json index 03a67ee2395..132825e3fe8 100644 --- a/packages/ui/src/theme/themes/oc-1.json +++ b/packages/ui/src/theme/themes/oc-1.json @@ -247,11 +247,6 @@ "markdown-code-block": "#1a1a1a", "border-color": "#ffffff", "border-weaker-base": "var(--smoke-light-alpha-3)", - "border-weaker-hover": "var(--smoke-light-alpha-4)", - "border-weaker-active": "var(--smoke-light-alpha-6)", - "border-weaker-selected": "var(--cobalt-light-alpha-4)", - "border-weaker-disabled": "var(--smoke-light-alpha-2)", - "border-weaker-focus": "var(--smoke-light-alpha-6)", "button-ghost-hover": "var(--smoke-light-alpha-2)", "button-ghost-hover2": "var(--smoke-light-alpha-3)", "avatar-background-pink": "#feeef8", @@ -513,11 +508,6 @@ "markdown-code-block": "#eeeeee", "border-color": "#ffffff", "border-weaker-base": "var(--smoke-dark-alpha-3)", - "border-weaker-hover": "var(--smoke-dark-alpha-4)", - "border-weaker-active": "var(--smoke-dark-alpha-6)", - "border-weaker-selected": "var(--cobalt-dark-alpha-3)", - "border-weaker-disabled": "var(--smoke-dark-alpha-2)", - "border-weaker-focus": "var(--smoke-dark-alpha-6)", "button-ghost-hover": "var(--smoke-dark-alpha-2)", "button-ghost-hover2": "var(--smoke-dark-alpha-3)", "avatar-background-pink": "#501b3f", diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index 01ec1131a2e..73ca57da9d8 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -4,7 +4,7 @@ "id": "oc-2", "light": { "seeds": { - "neutral": "#8e8b8b", + "neutral": "#8f8f8f", "primary": "#dcde8d", "success": "#12c905", "warning": "#ffdc17", @@ -15,32 +15,32 @@ "diffDelete": "#fc533a" }, "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--gray-light-3)", - "background-strong": "var(--gray-light-1)", + "background-base": "#f8f8f8", + "background-weak": "#f3f3f3", + "background-strong": "#fcfcfc", "background-stronger": "#fcfcfc", - "surface-base": "var(--gray-light-alpha-2)", - "base": "var(--gray-light-alpha-2)", - "surface-base-hover": "#0500000f", - "surface-base-active": "var(--gray-light-alpha-3)", + "surface-base": "#00000008", + "base": "#00000008", + "surface-base-hover": "#0000000f", + "surface-base-active": "#0000000d", "surface-base-interactive-active": "var(--cobalt-light-alpha-3)", - "base2": "var(--gray-light-alpha-2)", - "base3": "var(--gray-light-alpha-2)", - "surface-inset-base": "var(--gray-light-alpha-2)", - "surface-inset-base-hover": "var(--gray-light-alpha-3)", - "surface-inset-strong": "#1f000017", - "surface-inset-strong-hover": "#1f000017", - "surface-raised-base": "var(--gray-light-alpha-2)", - "surface-float-base": "var(--gray-dark-1)", - "surface-float-base-hover": "var(--gray-dark-2)", - "surface-raised-base-hover": "var(--gray-light-alpha-3)", - "surface-raised-base-active": "var(--gray-light-alpha-5)", - "surface-raised-strong": "var(--gray-light-1)", + "base2": "#00000008", + "base3": "#00000008", + "surface-inset-base": "#00000008", + "surface-inset-base-hover": "#0000000d", + "surface-inset-strong": "#00000017", + "surface-inset-strong-hover": "#00000017", + "surface-raised-base": "#00000008", + "surface-float-base": "#161616", + "surface-float-base-hover": "#1c1c1c", + "surface-raised-base-hover": "#0000000d", + "surface-raised-base-active": "#00000017", + "surface-raised-strong": "#fcfcfc", "surface-raised-strong-hover": "var(--white)", "surface-raised-stronger": "var(--white)", "surface-raised-stronger-hover": "var(--white)", - "surface-weak": "var(--gray-light-alpha-3)", - "surface-weaker": "var(--gray-light-alpha-4)", + "surface-weak": "#0000000d", + "surface-weaker": "#00000012", "surface-strong": "#ffffff", "surface-raised-stronger-non-alpha": "var(--white)", "surface-brand-base": "var(--yuzu-light-9)", @@ -62,7 +62,7 @@ "surface-info-weak": "var(--lilac-light-2)", "surface-info-strong": "var(--lilac-light-9)", "surface-diff-unchanged-base": "#ffffff00", - "surface-diff-skip-base": "var(--gray-light-2)", + "surface-diff-skip-base": "#f8f8f8", "surface-diff-hidden-base": "var(--blue-light-3)", "surface-diff-hidden-weak": "var(--blue-light-2)", "surface-diff-hidden-weaker": "var(--blue-light-1)", @@ -78,69 +78,69 @@ "surface-diff-delete-weaker": "var(--ember-light-1)", "surface-diff-delete-strong": "var(--ember-light-6)", "surface-diff-delete-stronger": "var(--ember-light-9)", - "input-base": "var(--gray-light-1)", - "input-hover": "var(--gray-light-2)", + "input-base": "#fcfcfc", + "input-hover": "#f8f8f8", "input-active": "var(--cobalt-light-1)", "input-selected": "var(--cobalt-light-4)", "input-focus": "var(--cobalt-light-1)", - "input-disabled": "var(--gray-light-4)", - "text-base": "var(--gray-light-11)", - "text-weak": "var(--gray-light-9)", - "text-weaker": "var(--gray-light-8)", - "text-strong": "var(--gray-light-12)", - "text-invert-base": "var(--gray-dark-alpha-11)", - "text-invert-weak": "var(--gray-dark-alpha-9)", - "text-invert-weaker": "var(--gray-dark-alpha-8)", - "text-invert-strong": "var(--gray-dark-alpha-12)", + "input-disabled": "#ededed", + "text-base": "#6f6f6f", + "text-weak": "#8f8f8f", + "text-weaker": "#c7c7c7", + "text-strong": "#171717", + "text-invert-base": "#ffffff96", + "text-invert-weak": "#ffffff63", + "text-invert-weaker": "#ffffff40", + "text-invert-strong": "#ffffffeb", "text-interactive-base": "var(--cobalt-light-9)", - "text-on-brand-base": "var(--gray-light-alpha-11)", - "text-on-interactive-base": "var(--gray-light-1)", - "text-on-interactive-weak": "var(--gray-dark-alpha-11)", + "text-on-brand-base": "#0000008f", + "text-on-interactive-base": "#fcfcfc", + "text-on-interactive-weak": "#ffffff96", "text-on-success-base": "var(--apple-light-10)", "text-on-critical-base": "var(--ember-light-10)", "text-on-critical-weak": "var(--ember-light-8)", "text-on-critical-strong": "var(--ember-light-12)", - "text-on-warning-base": "var(--gray-dark-alpha-11)", - "text-on-info-base": "var(--gray-dark-alpha-11)", + "text-on-warning-base": "#ffffff96", + "text-on-info-base": "#ffffff96", "text-diff-add-base": "var(--mint-light-11)", "text-diff-delete-base": "var(--ember-light-10)", "text-diff-delete-strong": "var(--ember-light-12)", "text-diff-add-strong": "var(--mint-light-12)", - "text-on-info-weak": "var(--gray-dark-alpha-9)", - "text-on-info-strong": "var(--gray-dark-alpha-12)", - "text-on-warning-weak": "var(--gray-dark-alpha-9)", - "text-on-warning-strong": "var(--gray-dark-alpha-12)", + "text-on-info-weak": "#ffffff63", + "text-on-info-strong": "#ffffffeb", + "text-on-warning-weak": "#ffffff63", + "text-on-warning-strong": "#ffffffeb", "text-on-success-weak": "var(--apple-light-6)", "text-on-success-strong": "var(--apple-light-12)", - "text-on-brand-weak": "var(--gray-light-alpha-9)", - "text-on-brand-weaker": "var(--gray-light-alpha-8)", - "text-on-brand-strong": "var(--gray-light-alpha-12)", - "button-primary-base": "var(--gray-light-12)", - "button-secondary-base": "var(--gray-light-1)", + "text-on-brand-weak": "#00000070", + "text-on-brand-weaker": "#00000038", + "text-on-brand-strong": "#000000e8", + "button-primary-base": "#171717", + "button-secondary-base": "#fcfcfc", "button-secondary-hover": "FFFFFF0A", - "border-base": "var(--gray-light-alpha-7)", - "border-hover": "var(--gray-light-alpha-8)", - "border-active": "var(--gray-light-alpha-9)", + "border-base": "#00000024", + "border-hover": "#00000038", + "border-active": "#00000070", "border-selected": "var(--cobalt-light-alpha-9)", - "border-disabled": "var(--gray-light-alpha-8)", - "border-focus": "var(--gray-light-alpha-9)", - "border-weak-base": "var(--gray-light-alpha-5)", - "border-strong-base": "var(--gray-light-alpha-7)", - "border-strong-hover": "var(--gray-light-alpha-8)", - "border-strong-active": "var(--gray-light-alpha-7)", + "border-disabled": "#00000038", + "border-focus": "#00000070", + "border-weak-base": "#e5e5e5", + "border-strong-base": "#00000024", + "border-strong-hover": "#00000038", + "border-strong-active": "#00000024", "border-strong-selected": "var(--cobalt-light-alpha-6)", - "border-strong-disabled": "var(--gray-light-alpha-6)", - "border-strong-focus": "var(--gray-light-alpha-7)", - "border-weak-hover": "var(--gray-light-alpha-6)", - "border-weak-active": "var(--gray-light-alpha-7)", + "border-strong-disabled": "#0000001c", + "border-strong-focus": "#00000024", + "border-weak-hover": "#0000001c", + "border-weak-active": "#00000024", "border-weak-selected": "var(--cobalt-light-alpha-5)", - "border-weak-disabled": "var(--gray-light-alpha-6)", - "border-weak-focus": "var(--gray-light-alpha-7)", + "border-weak-disabled": "#0000001c", + "border-weak-focus": "#00000024", "border-interactive-base": "var(--cobalt-light-7)", "border-interactive-hover": "var(--cobalt-light-8)", "border-interactive-active": "var(--cobalt-light-9)", "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--gray-light-8)", + "border-interactive-disabled": "#c7c7c7", "border-interactive-focus": "var(--cobalt-light-9)", "border-success-base": "var(--apple-light-6)", "border-success-hover": "var(--apple-light-7)", @@ -154,26 +154,26 @@ "border-info-base": "var(--lilac-light-6)", "border-info-hover": "var(--lilac-light-7)", "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--gray-light-9)", - "icon-hover": "var(--gray-light-11)", - "icon-active": "var(--gray-light-12)", - "icon-selected": "var(--gray-light-12)", - "icon-disabled": "var(--gray-light-8)", - "icon-focus": "var(--gray-light-12)", + "icon-base": "#8f8f8f", + "icon-hover": "#6f6f6f", + "icon-active": "#171717", + "icon-selected": "#171717", + "icon-disabled": "#c7c7c7", + "icon-focus": "#171717", "icon-invert-base": "#ffffff", - "icon-weak-base": "var(--gray-light-7)", - "icon-weak-hover": "var(--gray-light-8)", - "icon-weak-active": "var(--gray-light-9)", - "icon-weak-selected": "var(--gray-light-10)", - "icon-weak-disabled": "var(--gray-light-6)", - "icon-weak-focus": "var(--gray-light-9)", - "icon-strong-base": "var(--gray-light-12)", - "icon-strong-hover": "#151313", + "icon-weak-base": "#dbdbdb", + "icon-weak-hover": "#c7c7c7", + "icon-weak-active": "#8f8f8f", + "icon-weak-selected": "#858585", + "icon-weak-disabled": "#e2e2e2", + "icon-weak-focus": "#8f8f8f", + "icon-strong-base": "#171717", + "icon-strong-hover": "#151515", "icon-strong-active": "#020202", "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--gray-light-6)", + "icon-strong-disabled": "#e2e2e2", "icon-strong-focus": "#020202", - "icon-brand-base": "var(--gray-light-12)", + "icon-brand-base": "#171717", "icon-interactive-base": "var(--cobalt-light-9)", "icon-success-base": "var(--apple-light-7)", "icon-success-hover": "var(--apple-light-8)", @@ -187,10 +187,10 @@ "icon-info-base": "var(--lilac-light-7)", "icon-info-hover": "var(--lilac-light-8)", "icon-info-active": "var(--lilac-light-11)", - "icon-on-brand-base": "var(--gray-light-alpha-11)", - "icon-on-brand-hover": "var(--gray-light-alpha-12)", - "icon-on-brand-selected": "var(--gray-light-alpha-12)", - "icon-on-interactive-base": "var(--gray-light-1)", + "icon-on-brand-base": "#0000008f", + "icon-on-brand-hover": "#000000e8", + "icon-on-brand-selected": "#000000e8", + "icon-on-interactive-base": "#fcfcfc", "icon-agent-plan-base": "var(--purple-light-9)", "icon-agent-docs-base": "var(--amber-light-9)", "icon-agent-ask-base": "var(--cyan-light-9)", @@ -246,14 +246,9 @@ "markdown-image-text": "#318795", "markdown-code-block": "#1a1a1a", "border-color": "#ffffff", - "border-weaker-base": "var(--gray-light-alpha-3)", - "border-weaker-hover": "var(--gray-light-alpha-4)", - "border-weaker-active": "var(--gray-light-alpha-6)", - "border-weaker-selected": "var(--cobalt-light-alpha-4)", - "border-weaker-disabled": "var(--gray-light-alpha-2)", - "border-weaker-focus": "var(--gray-light-alpha-6)", - "button-ghost-hover": "var(--gray-light-alpha-2)", - "button-ghost-hover2": "var(--gray-light-alpha-3)", + "border-weaker-base": "#efefef", + "button-ghost-hover": "#00000008", + "button-ghost-hover2": "#0000000d", "avatar-background-pink": "#feeef8", "avatar-background-mint": "#e1fbf4", "avatar-background-orange": "#fff1e7", @@ -270,7 +265,7 @@ }, "dark": { "seeds": { - "neutral": "#716c6b", + "neutral": "#707070", "primary": "#fab283", "success": "#12c905", "warning": "#fcd53a", @@ -281,33 +276,33 @@ "diffDelete": "#fc533a" }, "overrides": { - "base": "var(--gray-dark-alpha-2)", - "base2": "var(--gray-dark-alpha-2)", - "base3": "var(--gray-dark-alpha-2)", + "base": "#ffffff08", + "base2": "#ffffff08", + "base3": "#ffffff08", "background-base": "#101010", "background-weak": "#1E1E1E", "background-strong": "#121212", "background-stronger": "#151515", - "surface-base": "var(--gray-dark-alpha-2)", + "surface-base": "#ffffff08", "surface-base-hover": "#FFFFFF0A", - "surface-base-active": "var(--gray-dark-alpha-3)", + "surface-base-active": "#ffffff0f", "surface-base-interactive-active": "var(--cobalt-dark-alpha-2)", - "surface-inset-base": "#0e0b0b7f", - "surface-inset-base-hover": "#0e0b0b7f", - "surface-inset-strong": "#060505cc", - "surface-inset-strong-hover": "#060505cc", - "surface-raised-base": "var(--gray-dark-alpha-3)", - "surface-float-base": "var(--gray-dark-1)", - "surface-float-base-hover": "var(--gray-dark-2)", - "surface-raised-base-hover": "var(--gray-dark-alpha-4)", - "surface-raised-base-active": "var(--gray-dark-alpha-5)", - "surface-raised-strong": "var(--gray-dark-alpha-4)", - "surface-raised-strong-hover": "var(--gray-dark-alpha-6)", - "surface-raised-stronger": "var(--gray-dark-alpha-6)", - "surface-raised-stronger-hover": "var(--gray-dark-alpha-7)", - "surface-weak": "var(--gray-dark-alpha-4)", - "surface-weaker": "var(--gray-dark-alpha-5)", - "surface-strong": "var(--gray-dark-alpha-7)", + "surface-inset-base": "#0000007f", + "surface-inset-base-hover": "#0000007f", + "surface-inset-strong": "#000000cc", + "surface-inset-strong-hover": "#000000cc", + "surface-raised-base": "#ffffff0f", + "surface-float-base": "#161616", + "surface-float-base-hover": "#1c1c1c", + "surface-raised-base-hover": "#ffffff14", + "surface-raised-base-active": "#ffffff1a", + "surface-raised-strong": "#ffffff14", + "surface-raised-strong-hover": "#ffffff21", + "surface-raised-stronger": "#ffffff21", + "surface-raised-stronger-hover": "#ffffff2b", + "surface-weak": "#ffffff14", + "surface-weaker": "#ffffff1a", + "surface-strong": "#ffffff2b", "surface-raised-stronger-non-alpha": "#1B1B1B", "surface-brand-base": "var(--yuzu-light-9)", "surface-brand-hover": "var(--yuzu-light-10)", @@ -327,8 +322,8 @@ "surface-info-base": "var(--lilac-light-3)", "surface-info-weak": "var(--lilac-light-2)", "surface-info-strong": "var(--lilac-light-9)", - "surface-diff-unchanged-base": "var(--gray-dark-1)", - "surface-diff-skip-base": "var(--gray-dark-alpha-1)", + "surface-diff-unchanged-base": "#161616", + "surface-diff-skip-base": "#00000000", "surface-diff-hidden-base": "var(--blue-dark-2)", "surface-diff-hidden-weak": "var(--blue-dark-1)", "surface-diff-hidden-weaker": "var(--blue-dark-3)", @@ -344,64 +339,64 @@ "surface-diff-delete-weaker": "var(--ember-dark-3)", "surface-diff-delete-strong": "var(--ember-dark-5)", "surface-diff-delete-stronger": "var(--ember-dark-11)", - "input-base": "var(--gray-dark-2)", - "input-hover": "var(--gray-dark-2)", + "input-base": "#1c1c1c", + "input-hover": "#1c1c1c", "input-active": "var(--cobalt-dark-1)", "input-selected": "var(--cobalt-dark-2)", "input-focus": "var(--cobalt-dark-1)", - "input-disabled": "var(--gray-dark-4)", - "text-base": "var(--gray-dark-alpha-11)", - "text-weak": "var(--gray-dark-alpha-9)", - "text-weaker": "var(--gray-dark-alpha-8)", - "text-strong": "var(--gray-dark-alpha-12)", - "text-invert-base": "var(--gray-dark-alpha-11)", - "text-invert-weak": "var(--gray-dark-alpha-9)", - "text-invert-weaker": "var(--gray-dark-alpha-8)", - "text-invert-strong": "var(--gray-dark-alpha-12)", + "input-disabled": "#282828", + "text-base": "#ffffff96", + "text-weak": "#ffffff63", + "text-weaker": "#ffffff40", + "text-strong": "#ffffffeb", + "text-invert-base": "#ffffff96", + "text-invert-weak": "#ffffff63", + "text-invert-weaker": "#ffffff40", + "text-invert-strong": "#ffffffeb", "text-interactive-base": "var(--cobalt-dark-11)", - "text-on-brand-base": "var(--gray-dark-alpha-11)", - "text-on-interactive-base": "var(--gray-dark-12)", - "text-on-interactive-weak": "var(--gray-dark-alpha-11)", + "text-on-brand-base": "#ffffff96", + "text-on-interactive-base": "#ededed", + "text-on-interactive-weak": "#ffffff96", "text-on-success-base": "var(--apple-dark-9)", "text-on-critical-base": "var(--ember-dark-9)", "text-on-critical-weak": "var(--ember-dark-8)", "text-on-critical-strong": "var(--ember-dark-12)", - "text-on-warning-base": "var(--gray-dark-alpha-11)", - "text-on-info-base": "var(--gray-dark-alpha-11)", + "text-on-warning-base": "#ffffff96", + "text-on-info-base": "#ffffff96", "text-diff-add-base": "var(--mint-dark-11)", "text-diff-delete-base": "var(--ember-dark-9)", "text-diff-delete-strong": "var(--ember-dark-12)", "text-diff-add-strong": "var(--mint-dark-8)", - "text-on-info-weak": "var(--gray-dark-alpha-9)", - "text-on-info-strong": "var(--gray-dark-alpha-12)", - "text-on-warning-weak": "var(--gray-dark-alpha-9)", - "text-on-warning-strong": "var(--gray-dark-alpha-12)", + "text-on-info-weak": "#ffffff63", + "text-on-info-strong": "#ffffffeb", + "text-on-warning-weak": "#ffffff63", + "text-on-warning-strong": "#ffffffeb", "text-on-success-weak": "var(--apple-dark-8)", "text-on-success-strong": "var(--apple-dark-12)", - "text-on-brand-weak": "var(--gray-dark-alpha-9)", - "text-on-brand-weaker": "var(--gray-dark-alpha-8)", - "text-on-brand-strong": "var(--gray-dark-alpha-12)", - "button-primary-base": "var(--gray-dark-12)", - "button-secondary-base": "var(--gray-dark-2)", + "text-on-brand-weak": "#ffffff63", + "text-on-brand-weaker": "#ffffff40", + "text-on-brand-strong": "#ffffffeb", + "button-primary-base": "#ededed", + "button-secondary-base": "#1c1c1c", "button-secondary-hover": "#FFFFFF0A", - "border-base": "var(--gray-dark-alpha-7)", - "border-hover": "var(--gray-dark-alpha-8)", - "border-active": "var(--gray-dark-alpha-9)", + "border-base": "#ffffff2b", + "border-hover": "#ffffff40", + "border-active": "#ffffff63", "border-selected": "var(--cobalt-dark-alpha-11)", - "border-disabled": "var(--gray-dark-alpha-8)", - "border-focus": "var(--gray-dark-alpha-9)", - "border-weak-base": "var(--gray-dark-alpha-5)", - "border-weak-hover": "var(--gray-dark-alpha-7)", - "border-weak-active": "var(--gray-dark-alpha-8)", + "border-disabled": "#ffffff40", + "border-focus": "#ffffff63", + "border-weak-base": "#282828", + "border-weak-hover": "#ffffff2b", + "border-weak-active": "#ffffff40", "border-weak-selected": "var(--cobalt-dark-alpha-6)", - "border-weak-disabled": "var(--gray-dark-alpha-6)", - "border-weak-focus": "var(--gray-dark-alpha-8)", - "border-strong-base": "var(--gray-dark-alpha-8)", + "border-weak-disabled": "#ffffff21", + "border-weak-focus": "#ffffff40", + "border-strong-base": "#ffffff40", "border-interactive-base": "var(--cobalt-light-7)", "border-interactive-hover": "var(--cobalt-light-8)", "border-interactive-active": "var(--cobalt-light-9)", "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--gray-light-8)", + "border-interactive-disabled": "#c7c7c7", "border-interactive-focus": "var(--cobalt-light-9)", "border-success-base": "var(--apple-light-6)", "border-success-hover": "var(--apple-light-7)", @@ -415,24 +410,24 @@ "border-info-base": "var(--lilac-light-6)", "border-info-hover": "var(--lilac-light-7)", "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--gray-dark-10)", - "icon-hover": "var(--gray-dark-11)", - "icon-active": "var(--gray-dark-12)", - "icon-selected": "var(--gray-dark-12)", - "icon-disabled": "var(--gray-dark-8)", - "icon-focus": "var(--gray-dark-12)", - "icon-invert-base": "var(--gray-dark-1)", - "icon-weak-base": "var(--gray-dark-6)", - "icon-weak-hover": "var(--gray-light-7)", - "icon-weak-active": "var(--gray-light-8)", - "icon-weak-selected": "var(--gray-light-9)", - "icon-weak-disabled": "var(--gray-light-4)", - "icon-weak-focus": "var(--gray-light-9)", - "icon-strong-base": "var(--gray-dark-12)", + "icon-base": "#7e7e7e", + "icon-hover": "#a0a0a0", + "icon-active": "#ededed", + "icon-selected": "#ededed", + "icon-disabled": "#505050", + "icon-focus": "#ededed", + "icon-invert-base": "#161616", + "icon-weak-base": "#343434", + "icon-weak-hover": "#dbdbdb", + "icon-weak-active": "#c7c7c7", + "icon-weak-selected": "#8f8f8f", + "icon-weak-disabled": "#ededed", + "icon-weak-focus": "#8f8f8f", + "icon-strong-base": "#ededed", "icon-strong-hover": "#F3F3F3", "icon-strong-active": "#EBEBEB", "icon-strong-selected": "#FCFCFC", - "icon-strong-disabled": "var(--gray-dark-7)", + "icon-strong-disabled": "#3e3e3e", "icon-strong-focus": "#FCFCFC", "icon-brand-base": "var(--white)", "icon-interactive-base": "var(--cobalt-dark-11)", @@ -448,10 +443,10 @@ "icon-info-base": "var(--lilac-dark-7)", "icon-info-hover": "var(--lilac-dark-8)", "icon-info-active": "var(--lilac-dark-11)", - "icon-on-brand-base": "var(--gray-light-alpha-11)", - "icon-on-brand-hover": "var(--gray-light-alpha-12)", - "icon-on-brand-selected": "var(--gray-light-alpha-12)", - "icon-on-interactive-base": "var(--gray-dark-12)", + "icon-on-brand-base": "#0000008f", + "icon-on-brand-hover": "#000000e8", + "icon-on-brand-selected": "#000000e8", + "icon-on-interactive-base": "#ededed", "icon-agent-plan-base": "var(--purple-dark-9)", "icon-agent-docs-base": "var(--amber-dark-9)", "icon-agent-ask-base": "var(--cyan-dark-9)", @@ -507,14 +502,9 @@ "markdown-image-text": "#56b6c2", "markdown-code-block": "#eeeeee", "border-color": "#ffffff", - "border-weaker-base": "var(--gray-dark-alpha-3)", - "border-weaker-hover": "var(--gray-dark-alpha-4)", - "border-weaker-active": "var(--gray-dark-alpha-6)", - "border-weaker-selected": "var(--cobalt-dark-alpha-3)", - "border-weaker-disabled": "var(--gray-dark-alpha-2)", - "border-weaker-focus": "var(--gray-dark-alpha-6)", - "button-ghost-hover": "var(--gray-dark-alpha-2)", - "button-ghost-hover2": "var(--gray-dark-alpha-3)", + "border-weaker-base": "#1e1e1e", + "button-ghost-hover": "#ffffff08", + "button-ghost-hover2": "#ffffff0f", "avatar-background-pink": "#501b3f", "avatar-background-mint": "#033a34", "avatar-background-orange": "#5f2a06", diff --git a/packages/util/src/array.ts b/packages/util/src/array.ts index 1fb8ac69ecd..91b923dee2a 100644 --- a/packages/util/src/array.ts +++ b/packages/util/src/array.ts @@ -1,3 +1,10 @@ +export function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + export function findLast( items: readonly T[], predicate: (item: T, index: number, items: readonly T[]) => boolean, diff --git a/script/upstream/utils/config.ts b/script/upstream/utils/config.ts index b0fe7318a58..69afecd77d3 100644 --- a/script/upstream/utils/config.ts +++ b/script/upstream/utils/config.ts @@ -113,6 +113,7 @@ export const defaultConfig: MergeConfig = { "README.th.md", "README.tr.md", "README.uk.md", + "README.vi.md", "README.zh.md", "README.zht.md", // Stats file diff --git a/turbo.json b/turbo.json index ba0d254ef83..bf1c18ab91e 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,9 @@ "globalEnv": ["CI", "KILO_DISABLE_SHARE"], "globalPassThroughEnv": ["CI", "KILO_DISABLE_SHARE"], "tasks": { - "typecheck": {}, + "typecheck": { + "dependsOn": ["^typecheck"] + }, "build": { "dependsOn": [], "outputs": ["dist/**"]