mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge branch 'dev' into opentui
This commit is contained in:
commit
8bf0ac5362
60 changed files with 1613 additions and 1901 deletions
4
.github/workflows/publish-vscode.yml
vendored
4
.github/workflows/publish-vscode.yml
vendored
|
|
@ -24,6 +24,10 @@ jobs:
|
|||
- run: git fetch --force --tags
|
||||
- run: bun install -g @vscode/vsce
|
||||
|
||||
- name: Install extension dependencies
|
||||
run: bun install
|
||||
working-directory: ./sdks/vscode
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
./script/publish
|
||||
|
|
|
|||
1
STATS.md
1
STATS.md
|
|
@ -115,3 +115,4 @@
|
|||
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
|
||||
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
|
||||
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
|
||||
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
|
||||
|
|
|
|||
87
bun.lock
87
bun.lock
|
|
@ -117,6 +117,7 @@
|
|||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
|
|
@ -286,7 +287,9 @@
|
|||
"version": "0.15.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
|
@ -1019,6 +1022,8 @@
|
|||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.0.2-alpha.1-1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
|
||||
|
|
@ -1189,9 +1194,9 @@
|
|||
|
||||
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.3", "", { "dependencies": { "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-xWL9Mf8b7tIFuAlpjKtRPnHrR8XVrwTj5NPYO/QwZPtc0SDLsPxb56V5tzi5yspSMytISHybifez+4jlrx0vkQ=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.3", "@smithy/types": "^4.8.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.3", "tslib": "^2.6.2" } }, "sha512-xSql8A1Bl41O9JvGU/CtgiLBlwkvpHTSKRlvz9zOBvBCPjXghZ6ZkcVzmV2f7FLAA+80+aqKmIOmy8pEDrtCaw=="],
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.0", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.3", "@smithy/types": "^4.8.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.3", "@smithy/util-middleware": "^4.2.3", "tslib": "^2.6.2" } }, "sha512-Kkmz3Mup2PGp/HNJxhCWkLNdlajJORLSjwkcfrj0E7nu6STAEdcMR1ir5P9/xOmncx8xXfru0fbUYLlZog/cFg=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.17.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-stream": "^4.5.3", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Tir3DbfoTO97fEGUZjzGeoXgcQAUBRDTmuH9A8lxuP8ATrgezrAJ6cLuRvwdKN4ZbYNlHgKlBX69Hyu3THYhtg=="],
|
||||
"@smithy/core": ["@smithy/core@3.17.1", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-stream": "^4.5.4", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-V4Qc2CIb5McABYfaGiIYLTmo/vwNIK7WXI5aGveBd9UcdhbOMwcvIMxIw/DJj1S9QgOMa/7FBkarMdIC0EOTEQ=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.3", "@smithy/property-provider": "^4.2.3", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "tslib": "^2.6.2" } }, "sha512-hA1MQ/WAHly4SYltJKitEsIDVsNmXcQfYBRv2e+q04fnqtAX5qXaybxy/fhUeAMCnQIdAjaGDb04fMHQefWRhw=="],
|
||||
|
||||
|
|
@ -1207,9 +1212,9 @@
|
|||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.3", "", { "dependencies": { "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-/atXLsT88GwKtfp5Jr0Ks1CSa4+lB+IgRnkNrrYP0h1wL4swHNb0YONEvTceNKNdZGJsye+W2HH8W7olbcPUeA=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.3.4", "", { "dependencies": { "@smithy/core": "^3.17.0", "@smithy/middleware-serde": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "@smithy/util-middleware": "^4.2.3", "tslib": "^2.6.2" } }, "sha512-/RJhpYkMOaUZoJEkddamGPPIYeKICKXOu/ojhn85dKDM0n5iDIhjvYAQLP3K5FPhgB203O3GpWzoK2OehEoIUw=="],
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.3.5", "", { "dependencies": { "@smithy/core": "^3.17.1", "@smithy/middleware-serde": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "@smithy/url-parser": "^4.2.3", "@smithy/util-middleware": "^4.2.3", "tslib": "^2.6.2" } }, "sha512-SIzKVTvEudFWJbxAaq7f2GvP3jh2FHDpIFI6/VAf4FOWGFZy0vnYMPSRj8PGYI8Hjt29mvmwSRgKuO3bK4ixDw=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.4", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.3", "@smithy/protocol-http": "^5.3.3", "@smithy/service-error-classification": "^4.2.3", "@smithy/smithy-client": "^4.9.0", "@smithy/types": "^4.8.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-retry": "^4.2.3", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-vSgABQAkuUHRO03AhR2rWxVQ1un284lkBn+NFawzdahmzksAoOeVMnXXsuPViL4GlhRHXqFaMlc8Mj04OfQk1w=="],
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.3", "@smithy/protocol-http": "^5.3.3", "@smithy/service-error-classification": "^4.2.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-retry": "^4.2.3", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-DCaXbQqcZ4tONMvvdz+zccDE21sLcbwWoNqzPLFlZaxt1lDtOE2tlVpRSwcTOJrjJSUThdgEYn7HrX5oLGlK9A=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.3", "", { "dependencies": { "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-8g4NuUINpYccxiCXM5s1/V+uLtts8NcX4+sPEbvYQDZk4XoJfDpq5y2FQxfmUL89syoldpzNzA0R9nhzdtdKnQ=="],
|
||||
|
||||
|
|
@ -1217,7 +1222,7 @@
|
|||
|
||||
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.3", "", { "dependencies": { "@smithy/property-provider": "^4.2.3", "@smithy/shared-ini-file-loader": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-NzI1eBpBSViOav8NVy1fqOlSfkLgkUjUTlohUSgAEhHaFWA3XJiLditvavIP7OpvTjDp5u2LhtlBhkBlEisMwA=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.2", "", { "dependencies": { "@smithy/abort-controller": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/querystring-builder": "^4.2.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-MHFvTjts24cjGo1byXqhXrbqm7uznFD/ESFx8npHMWTFQVdBZjrT1hKottmp69LBTRm/JQzP/sn1vPt0/r6AYQ=="],
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.3", "", { "dependencies": { "@smithy/abort-controller": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/querystring-builder": "^4.2.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-MAwltrDB0lZB/H6/2M5PIsISSwdI5yIh6DaBB9r0Flo9nx3y0dzl/qTMJPd7tJvPdsx6Ks/cwVzheGNYzXyNbQ=="],
|
||||
|
||||
"@smithy/property-provider": ["@smithy/property-provider@4.2.3", "", { "dependencies": { "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-+1EZ+Y+njiefCohjlhyOcy1UNYjT+1PwGFHCxA/gYctjg3DQWAU19WigOXAco/Ql8hZokNehpzLd0/+3uCreqQ=="],
|
||||
|
||||
|
|
@ -1233,7 +1238,7 @@
|
|||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.3", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.3", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-CmSlUy+eEYbIEYN5N3vvQTRfqt0lJlQkaQUIf+oizu7BbDut0pozfDjBGecfcfWf7c62Yis4JIEgqQ/TCfodaA=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.9.0", "", { "dependencies": { "@smithy/core": "^3.17.0", "@smithy/middleware-endpoint": "^4.3.4", "@smithy/middleware-stack": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "@smithy/util-stream": "^4.5.3", "tslib": "^2.6.2" } }, "sha512-qz7RTd15GGdwJ3ZCeBKLDQuUQ88m+skh2hJwcpPm1VqLeKzgZvXf6SrNbxvx7uOqvvkjCMXqx3YB5PDJyk00ww=="],
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.9.1", "", { "dependencies": { "@smithy/core": "^3.17.1", "@smithy/middleware-endpoint": "^4.3.5", "@smithy/middleware-stack": "^4.2.3", "@smithy/protocol-http": "^5.3.3", "@smithy/types": "^4.8.0", "@smithy/util-stream": "^4.5.4", "tslib": "^2.6.2" } }, "sha512-Ngb95ryR5A9xqvQFT5mAmYkCwbXvoLavLFwmi7zVg/IowFPCfiqRfkOKnbc/ZRL8ZKJ4f+Tp6kSu6wjDQb8L/g=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.8.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ=="],
|
||||
|
||||
|
|
@ -1249,9 +1254,9 @@
|
|||
|
||||
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.3", "", { "dependencies": { "@smithy/property-provider": "^4.2.3", "@smithy/smithy-client": "^4.9.0", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-vqHoybAuZXbFXZqgzquiUXtdY+UT/aU33sxa4GBPkiYklmR20LlCn+d3Wc3yA5ZM13gQ92SZe/D8xh6hkjx+IQ=="],
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.4", "", { "dependencies": { "@smithy/property-provider": "^4.2.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-qI5PJSW52rnutos8Bln8nwQZRpyoSRN6k2ajyoUHNMUzmWqHnOJCnDELJuV6m5PML0VkHI+XcXzdB+6awiqYUw=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.4", "", { "dependencies": { "@smithy/config-resolver": "^4.3.3", "@smithy/credential-provider-imds": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/property-provider": "^4.2.3", "@smithy/smithy-client": "^4.9.0", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-X5/xrPHedifo7hJUUWKlpxVb2oDOiqPUXlvsZv1EZSjILoutLiJyWva3coBpn00e/gPSpH8Rn2eIbgdwHQdW7Q=="],
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.6", "", { "dependencies": { "@smithy/config-resolver": "^4.4.0", "@smithy/credential-provider-imds": "^4.2.3", "@smithy/node-config-provider": "^4.3.3", "@smithy/property-provider": "^4.2.3", "@smithy/smithy-client": "^4.9.1", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-c6M/ceBTm31YdcFpgfgQAJaw3KbaLuRKnAz91iMWFLSrgxRpYm03c3bu5cpYojNMfkV9arCUelelKA7XQT36SQ=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-aCfxUOVv0CzBIkU10TubdgKSx5uRvzH064kaiPEWfNIvKOtNpu642P4FP1hgOFkjQIkDObrfIDnKMKkeyrejvQ=="],
|
||||
|
||||
|
|
@ -1261,7 +1266,7 @@
|
|||
|
||||
"@smithy/util-retry": ["@smithy/util-retry@4.2.3", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.3", "@smithy/types": "^4.8.0", "tslib": "^2.6.2" } }, "sha512-lLPWnakjC0q9z+OtiXk+9RPQiYPNAovt2IXD3CP4LkOnd9NpUsxOjMx1SnoUVB7Orb7fZp67cQMtTBKMFDvOGg=="],
|
||||
|
||||
"@smithy/util-stream": ["@smithy/util-stream@4.5.3", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.4", "@smithy/node-http-handler": "^4.4.2", "@smithy/types": "^4.8.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-oZvn8a5bwwQBNYHT2eNo0EU8Kkby3jeIg1P2Lu9EQtqDxki1LIjGRJM6dJ5CZUig8QmLxWxqOKWvg3mVoOBs5A=="],
|
||||
"@smithy/util-stream": ["@smithy/util-stream@4.5.4", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.4", "@smithy/node-http-handler": "^4.4.3", "@smithy/types": "^4.8.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-+qDxSkiErejw1BAIXUFBSfM5xh3arbz1MmxlbMCKanDDZtVEQ7PSKW9FQS0Vud1eI/kYn0oCTVKyNzRlq+9MUw=="],
|
||||
|
||||
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
|
||||
|
||||
|
|
@ -1269,6 +1274,8 @@
|
|||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
|
||||
|
||||
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
|
||||
|
||||
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
|
||||
|
||||
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
|
||||
|
|
@ -1589,9 +1596,9 @@
|
|||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bare-events": ["bare-events@2.8.0", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA=="],
|
||||
"bare-events": ["bare-events@2.8.1", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ=="],
|
||||
|
||||
"bare-fs": ["bare-fs@4.4.11", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA=="],
|
||||
"bare-fs": ["bare-fs@4.5.0", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-GljgCjeupKZJNetTqxKaQArLK10vpmK28or0+RwWjEl5Rk+/xG3wkpmkv+WrcBm3q1BwHKlnhXzR8O37kcvkXQ=="],
|
||||
|
||||
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
|
||||
|
||||
|
|
@ -1641,7 +1648,7 @@
|
|||
|
||||
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="],
|
||||
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
|
||||
|
||||
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
||||
|
||||
|
|
@ -1851,7 +1858,7 @@
|
|||
|
||||
"deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="],
|
||||
|
||||
"devalue": ["devalue@5.4.1", "", {}, "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ=="],
|
||||
"devalue": ["devalue@5.4.2", "", {}, "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
|
|
@ -1899,7 +1906,7 @@
|
|||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.237", "", {}, "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.239", "", {}, "sha512-1y5w0Zsq39MSPmEjHjbizvhYoTaulVtivpxkp5q5kaPmQtsK6/2nvAzGRxNMS9DoYySp9PkW0MAQDwU1m764mg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
|
|
@ -2101,7 +2108,7 @@
|
|||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.12.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw=="],
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
|
|
@ -3415,7 +3422,7 @@
|
|||
|
||||
"unwasm": ["unwasm@0.3.11", "", { "dependencies": { "knitwork": "^1.2.0", "magic-string": "^0.30.17", "mlly": "^1.7.4", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "unplugin": "^2.3.6" } }, "sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
||||
|
||||
"uqr": ["uqr@0.1.2", "", {}, "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA=="],
|
||||
|
||||
|
|
@ -3577,6 +3584,8 @@
|
|||
|
||||
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.8", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.4", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.13.0", "smol-toml": "^1.4.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-uFNyFWadnULWK2cOw4n0hLKeu+xaVWeuECdP10cQ3K2fkybtTlhb7J7TcScdjmS8Yps7oje9S/ehYMfZrhrgCg=="],
|
||||
|
||||
"@astrojs/sitemap/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
|
@ -3707,6 +3716,12 @@
|
|||
|
||||
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
|
@ -3777,6 +3792,8 @@
|
|||
|
||||
"astro/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"astro/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
|
||||
|
||||
"astro/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
|
@ -4093,6 +4110,18 @@
|
|||
|
||||
"@actions/github/@octokit/request-error/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
|
||||
|
||||
"@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.4", "", {}, "sha512-lDA9MqE8WGi7T/t2BMi+EAXhs4Vcvr94Gqx3q15cFEz8oFZMO4/SFBqYr/UcmNlvW+35alowkVj+w9VhLvs5Cw=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||
|
|
@ -4287,6 +4316,20 @@
|
|||
|
||||
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
|
||||
|
|
@ -4323,6 +4366,18 @@
|
|||
|
||||
"archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"astro/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
|
||||
"astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||
|
||||
"astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
||||
|
||||
"astro/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
|
||||
|
||||
"astro/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
|
||||
|
||||
"astro/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"axios/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@
|
|||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
|
|
@ -33,14 +35,13 @@
|
|||
"@solidjs/router": "0.15.3",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"shiki": "3.9.2",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:"
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ export function Code(props: Props) {
|
|||
[&_.diff-blank_.diff-oldln]:bg-background-element
|
||||
[&_.diff-blank_.diff-newln]:bg-background-element
|
||||
[&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative
|
||||
[&_.diff-collapsed]:cursor-pointer [&_.diff-collapsed]:select-none
|
||||
[&_.diff-collapsed]:select-none
|
||||
[&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40!
|
||||
[&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info
|
||||
[&_.diff-collapsed]:text-xs
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
|
||||
import { Tabs, Tooltip } from "@opencode-ai/ui"
|
||||
import { Icon } from "@opencode-ai/ui"
|
||||
import { FileIcon, IconButton } from "@/ui"
|
||||
import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
|
|
@ -92,20 +91,16 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="file-text"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
|
|
@ -113,13 +108,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="checklist"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
|
|
@ -127,13 +120,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="columns"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
|
|
@ -141,9 +132,7 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
|||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -221,13 +210,11 @@ function SortableTab(props: {
|
|||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default function FileTree(props: {
|
|||
<Dynamic
|
||||
component={p.as ?? "div"}
|
||||
classList={{
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true,
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
|
|
@ -83,7 +83,7 @@ export default function FileTree(props: {
|
|||
>
|
||||
<Collapsible.Trigger>
|
||||
<Node node={node}>
|
||||
<Collapsible.Arrow size={16} class="text-text-muted/60 ml-1" />
|
||||
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
||||
<FileIcon
|
||||
node={node}
|
||||
expanded={local.file.node(node.path).expanded}
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import type { TextSelection } from "@/context/local"
|
||||
import { getFilename } from "@/utils"
|
||||
|
||||
export interface PromptTextPart {
|
||||
kind: "text"
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface PromptAttachmentPart {
|
||||
kind: "attachment"
|
||||
token: string
|
||||
display: string
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
origin: "context" | "active"
|
||||
}
|
||||
|
||||
export interface PromptInterimPart {
|
||||
kind: "interim"
|
||||
value: string
|
||||
leadingSpace: boolean
|
||||
}
|
||||
|
||||
export type PromptContentPart = PromptTextPart | PromptAttachmentPart
|
||||
|
||||
export type PromptDisplaySegment =
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "attachment"; part: PromptAttachmentPart; source: string }
|
||||
| PromptInterimPart
|
||||
|
||||
export interface AttachmentCandidate {
|
||||
origin: "context" | "active"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
display: string
|
||||
}
|
||||
|
||||
export interface PromptSubmitValue {
|
||||
text: string
|
||||
parts: PromptContentPart[]
|
||||
}
|
||||
|
||||
export const mentionPattern = /@([A-Za-z0-9_\-./]+)/g
|
||||
export const mentionTriggerPattern = /(^|\s)@([A-Za-z0-9_\-./]*)$/
|
||||
|
||||
export type PromptSegment = (PromptTextPart | PromptAttachmentPart) & {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type PromptAttachmentSegment = PromptAttachmentPart & {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function pushTextPart(parts: PromptContentPart[], value: string) {
|
||||
if (!value) return
|
||||
const last = parts[parts.length - 1]
|
||||
if (last && last.kind === "text") {
|
||||
last.value += value
|
||||
return
|
||||
}
|
||||
parts.push({ kind: "text", value })
|
||||
}
|
||||
|
||||
function addTextSegment(segments: PromptSegment[], start: number, value: string) {
|
||||
if (!value) return
|
||||
segments.push({ kind: "text", value, start, end: start + value.length })
|
||||
}
|
||||
|
||||
export function createAttachmentDisplay(path: string, selection?: TextSelection) {
|
||||
const base = getFilename(path)
|
||||
if (!selection) return base
|
||||
return `${base} (${selection.startLine}-${selection.endLine})`
|
||||
}
|
||||
|
||||
export function registerCandidate(
|
||||
map: Map<string, AttachmentCandidate>,
|
||||
candidate: AttachmentCandidate,
|
||||
tokens: (string | undefined)[],
|
||||
) {
|
||||
for (const token of tokens) {
|
||||
if (!token) continue
|
||||
const normalized = token.toLowerCase()
|
||||
if (map.has(normalized)) continue
|
||||
map.set(normalized, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePrompt(value: string, lookup: Map<string, AttachmentCandidate>) {
|
||||
const segments: PromptSegment[] = []
|
||||
if (!value) return { parts: [] as PromptContentPart[], segments }
|
||||
|
||||
const pushTextRange = (rangeStart: number, rangeEnd: number) => {
|
||||
if (rangeEnd <= rangeStart) return
|
||||
const text = value.slice(rangeStart, rangeEnd)
|
||||
let cursor = 0
|
||||
for (const match of text.matchAll(mentionPattern)) {
|
||||
const localIndex = match.index ?? 0
|
||||
if (localIndex > cursor) {
|
||||
addTextSegment(segments, rangeStart + cursor, text.slice(cursor, localIndex))
|
||||
}
|
||||
const token = match[1]
|
||||
const candidate = lookup.get(token.toLowerCase())
|
||||
if (candidate) {
|
||||
const start = rangeStart + localIndex
|
||||
const end = start + match[0].length
|
||||
segments.push({
|
||||
kind: "attachment",
|
||||
token,
|
||||
display: candidate.display,
|
||||
path: candidate.path,
|
||||
selection: candidate.selection,
|
||||
origin: candidate.origin,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
} else {
|
||||
addTextSegment(segments, rangeStart + localIndex, match[0])
|
||||
}
|
||||
cursor = localIndex + match[0].length
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
addTextSegment(segments, rangeStart + cursor, text.slice(cursor))
|
||||
}
|
||||
}
|
||||
|
||||
pushTextRange(0, value.length)
|
||||
|
||||
const parts: PromptContentPart[] = []
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === "text") {
|
||||
pushTextPart(parts, segment.value)
|
||||
} else {
|
||||
const { start, end, ...attachment } = segment
|
||||
parts.push(attachment as PromptAttachmentPart)
|
||||
}
|
||||
}
|
||||
return { parts, segments }
|
||||
}
|
||||
|
||||
export function composeDisplaySegments(
|
||||
segments: PromptSegment[],
|
||||
inputValue: string,
|
||||
interim: string,
|
||||
): PromptDisplaySegment[] {
|
||||
if (segments.length === 0 && !interim) return []
|
||||
|
||||
const display: PromptDisplaySegment[] = segments.map((segment) => {
|
||||
if (segment.kind === "text") {
|
||||
return { kind: "text", value: segment.value }
|
||||
}
|
||||
const { start, end, ...part } = segment
|
||||
const placeholder = inputValue.slice(start, end)
|
||||
return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder }
|
||||
})
|
||||
|
||||
if (interim) {
|
||||
const leadingSpace = !!(inputValue && !inputValue.endsWith(" ") && !interim.startsWith(" "))
|
||||
display.push({ kind: "interim", value: interim, leadingSpace })
|
||||
}
|
||||
|
||||
return display
|
||||
}
|
||||
|
|
@ -1,396 +0,0 @@
|
|||
import { createEffect, createMemo, createResource, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { createSpeechRecognition } from "@/utils/speech"
|
||||
import {
|
||||
createAttachmentDisplay,
|
||||
mentionPattern,
|
||||
mentionTriggerPattern,
|
||||
type PromptAttachmentPart,
|
||||
type PromptAttachmentSegment,
|
||||
} from "./prompt-form-helpers"
|
||||
import type { LocalFile, TextSelection } from "@/context/local"
|
||||
|
||||
export type MentionRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface PromptFormState {
|
||||
promptInput: string
|
||||
isDragOver: boolean
|
||||
mentionOpen: boolean
|
||||
mentionQuery: string
|
||||
mentionRange: MentionRange | undefined
|
||||
mentionIndex: number
|
||||
mentionAnchorOffset: { x: number; y: number }
|
||||
inlineAliases: Map<string, PromptAttachmentPart>
|
||||
}
|
||||
|
||||
interface MentionControllerOptions {
|
||||
state: PromptFormState
|
||||
setState: SetStoreFunction<PromptFormState>
|
||||
attachmentSegments: Accessor<PromptAttachmentSegment[]>
|
||||
getInputRef: () => HTMLTextAreaElement | undefined
|
||||
getOverlayRef: () => HTMLDivElement | undefined
|
||||
getMeasureRef: () => HTMLDivElement | undefined
|
||||
searchFiles: (query: string) => Promise<string[]>
|
||||
resolveFile: (path: string) => LocalFile | undefined
|
||||
addContextFile: (path: string, selection?: TextSelection) => void
|
||||
getActiveContext: () => { path: string; selection?: TextSelection } | undefined
|
||||
}
|
||||
|
||||
interface MentionKeyDownOptions {
|
||||
event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }
|
||||
mentionItems: () => string[]
|
||||
insertMention: (path: string) => void
|
||||
}
|
||||
|
||||
interface ScrollSyncOptions {
|
||||
state: PromptFormState
|
||||
getInputRef: () => HTMLTextAreaElement | undefined
|
||||
getOverlayRef: () => HTMLDivElement | undefined
|
||||
interim: Accessor<string>
|
||||
updateMentionPosition: (element: HTMLTextAreaElement, range?: MentionRange) => void
|
||||
}
|
||||
|
||||
export function usePromptSpeech(updatePromptInput: (updater: (prev: string) => string) => void) {
|
||||
return createSpeechRecognition({
|
||||
onFinal: (text) => updatePromptInput((prev) => (prev && !prev.endsWith(" ") ? `${prev} ` : prev) + text),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMentionController(options: MentionControllerOptions) {
|
||||
const mentionSource = createMemo(() => (options.state.mentionOpen ? options.state.mentionQuery : undefined))
|
||||
const [mentionResults, { mutate: mutateMentionResults }] = createResource(mentionSource, (query) => {
|
||||
if (!options.state.mentionOpen) return []
|
||||
return options.searchFiles(query ?? "")
|
||||
})
|
||||
const mentionItems = createMemo(() => mentionResults() ?? [])
|
||||
|
||||
createEffect(() => {
|
||||
if (!options.state.mentionOpen) return
|
||||
options.state.mentionQuery
|
||||
options.setState("mentionIndex", 0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!options.state.mentionOpen) return
|
||||
queueMicrotask(() => {
|
||||
const input = options.getInputRef()
|
||||
if (!input) return
|
||||
if (document.activeElement === input) return
|
||||
input.focus()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const used = new Set<string>()
|
||||
for (const match of options.state.promptInput.matchAll(mentionPattern)) {
|
||||
const token = match[1]
|
||||
if (token) used.add(token.toLowerCase())
|
||||
}
|
||||
options.setState("inlineAliases", (prev) => {
|
||||
if (prev.size === 0) return prev
|
||||
const next = new Map(prev)
|
||||
let changed = false
|
||||
for (const key of prev.keys()) {
|
||||
if (!used.has(key.toLowerCase())) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!options.state.mentionOpen) return
|
||||
const items = mentionItems()
|
||||
if (items.length === 0) {
|
||||
options.setState("mentionIndex", 0)
|
||||
return
|
||||
}
|
||||
if (options.state.mentionIndex < items.length) return
|
||||
options.setState("mentionIndex", items.length - 1)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!options.state.mentionOpen) return
|
||||
const rangeValue = options.state.mentionRange
|
||||
if (!rangeValue) return
|
||||
options.state.promptInput
|
||||
queueMicrotask(() => {
|
||||
const input = options.getInputRef()
|
||||
if (!input) return
|
||||
updateMentionPosition(input, rangeValue)
|
||||
})
|
||||
})
|
||||
|
||||
function closeMention() {
|
||||
if (options.state.mentionOpen) options.setState("mentionOpen", false)
|
||||
options.setState("mentionQuery", "")
|
||||
options.setState("mentionRange", undefined)
|
||||
options.setState("mentionIndex", 0)
|
||||
mutateMentionResults(() => undefined)
|
||||
options.setState("mentionAnchorOffset", { x: 0, y: 0 })
|
||||
}
|
||||
|
||||
function updateMentionPosition(element: HTMLTextAreaElement, rangeValue = options.state.mentionRange) {
|
||||
const measure = options.getMeasureRef()
|
||||
if (!measure) return
|
||||
if (!rangeValue) return
|
||||
measure.style.width = `${element.clientWidth}px`
|
||||
const measurement = element.value.slice(0, rangeValue.end)
|
||||
measure.textContent = measurement
|
||||
const caretSpan = document.createElement("span")
|
||||
caretSpan.textContent = "\u200b"
|
||||
measure.append(caretSpan)
|
||||
const caretRect = caretSpan.getBoundingClientRect()
|
||||
const containerRect = measure.getBoundingClientRect()
|
||||
measure.removeChild(caretSpan)
|
||||
const left = caretRect.left - containerRect.left
|
||||
const top = caretRect.top - containerRect.top - element.scrollTop
|
||||
options.setState("mentionAnchorOffset", { x: left, y: top < 0 ? 0 : top })
|
||||
}
|
||||
|
||||
function isValidMentionQuery(value: string) {
|
||||
return /^[A-Za-z0-9_\-./]*$/.test(value)
|
||||
}
|
||||
|
||||
function syncMentionFromCaret(element: HTMLTextAreaElement) {
|
||||
if (!options.state.mentionOpen) return
|
||||
const rangeValue = options.state.mentionRange
|
||||
if (!rangeValue) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
const caret = element.selectionEnd ?? element.selectionStart ?? element.value.length
|
||||
if (rangeValue.start < 0 || rangeValue.start >= element.value.length) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
if (element.value[rangeValue.start] !== "@") {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
if (caret <= rangeValue.start) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
const mentionValue = element.value.slice(rangeValue.start + 1, caret)
|
||||
if (!isValidMentionQuery(mentionValue)) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
options.setState("mentionRange", { start: rangeValue.start, end: caret })
|
||||
options.setState("mentionQuery", mentionValue)
|
||||
updateMentionPosition(element, { start: rangeValue.start, end: caret })
|
||||
}
|
||||
|
||||
function tryOpenMentionFromCaret(element: HTMLTextAreaElement) {
|
||||
const selectionStart = element.selectionStart ?? element.value.length
|
||||
const selectionEnd = element.selectionEnd ?? selectionStart
|
||||
if (selectionStart !== selectionEnd) return false
|
||||
const caret = selectionEnd
|
||||
if (options.attachmentSegments().some((segment) => caret >= segment.start && caret <= segment.end)) {
|
||||
return false
|
||||
}
|
||||
const before = element.value.slice(0, caret)
|
||||
const match = before.match(mentionTriggerPattern)
|
||||
if (!match) return false
|
||||
const token = match[2] ?? ""
|
||||
const start = caret - token.length - 1
|
||||
if (start < 0) return false
|
||||
options.setState("mentionOpen", true)
|
||||
options.setState("mentionRange", { start, end: caret })
|
||||
options.setState("mentionQuery", token)
|
||||
options.setState("mentionIndex", 0)
|
||||
queueMicrotask(() => {
|
||||
updateMentionPosition(element, { start, end: caret })
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function handlePromptInput(event: InputEvent & { currentTarget: HTMLTextAreaElement }) {
|
||||
const element = event.currentTarget
|
||||
options.setState("promptInput", element.value)
|
||||
if (options.state.mentionOpen) {
|
||||
syncMentionFromCaret(element)
|
||||
if (options.state.mentionOpen) return
|
||||
}
|
||||
const isDeletion = event.inputType ? event.inputType.startsWith("delete") : false
|
||||
if (!isDeletion && tryOpenMentionFromCaret(element)) return
|
||||
closeMention()
|
||||
}
|
||||
|
||||
function handleMentionKeyDown({ event, mentionItems: items, insertMention }: MentionKeyDownOptions) {
|
||||
if (!options.state.mentionOpen) return false
|
||||
const list = items()
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
if (list.length === 0) return true
|
||||
const next = options.state.mentionIndex + 1 >= list.length ? 0 : options.state.mentionIndex + 1
|
||||
options.setState("mentionIndex", next)
|
||||
return true
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
if (list.length === 0) return true
|
||||
const previous = options.state.mentionIndex - 1 < 0 ? list.length - 1 : options.state.mentionIndex - 1
|
||||
options.setState("mentionIndex", previous)
|
||||
return true
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
const targetItem = list[options.state.mentionIndex] ?? list[0]
|
||||
if (targetItem) insertMention(targetItem)
|
||||
return true
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeMention()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function generateMentionAlias(path: string) {
|
||||
const existing = new Set<string>()
|
||||
for (const key of options.state.inlineAliases.keys()) {
|
||||
existing.add(key.toLowerCase())
|
||||
}
|
||||
for (const match of options.state.promptInput.matchAll(mentionPattern)) {
|
||||
const token = match[1]
|
||||
if (token) existing.add(token.toLowerCase())
|
||||
}
|
||||
|
||||
const base = getFilename(path)
|
||||
if (base) {
|
||||
if (!existing.has(base.toLowerCase())) return base
|
||||
}
|
||||
|
||||
const directory = getDirectory(path)
|
||||
if (base && directory) {
|
||||
const segments = directory.split("/").filter(Boolean)
|
||||
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = `${segments.slice(i).join("/")}/${base}`
|
||||
if (!existing.has(candidate.toLowerCase())) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
if (!existing.has(path.toLowerCase())) return path
|
||||
|
||||
const fallback = base || path || "file"
|
||||
let index = 2
|
||||
let candidate = `${fallback}-${index}`
|
||||
while (existing.has(candidate.toLowerCase())) {
|
||||
index += 1
|
||||
candidate = `${fallback}-${index}`
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function insertMention(path: string) {
|
||||
const input = options.getInputRef()
|
||||
if (!input) return
|
||||
const rangeValue = options.state.mentionRange
|
||||
if (!rangeValue) return
|
||||
const node = options.resolveFile(path)
|
||||
const alias = generateMentionAlias(path)
|
||||
const mentionText = `@${alias}`
|
||||
const value = options.state.promptInput
|
||||
const before = value.slice(0, rangeValue.start)
|
||||
const after = value.slice(rangeValue.end)
|
||||
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
|
||||
const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
|
||||
const leading = needsLeadingSpace ? `${before} ` : before
|
||||
const trailingSpacer = needsTrailingSpace ? " " : ""
|
||||
const nextValue = `${leading}${mentionText}${trailingSpacer}${after}`
|
||||
const origin = options.getActiveContext()?.path === path ? "active" : "context"
|
||||
const part: PromptAttachmentPart = {
|
||||
kind: "attachment",
|
||||
token: alias,
|
||||
display: createAttachmentDisplay(path, node?.selection),
|
||||
path,
|
||||
selection: node?.selection,
|
||||
origin,
|
||||
}
|
||||
options.setState("promptInput", nextValue)
|
||||
if (input.value !== nextValue) {
|
||||
input.value = nextValue
|
||||
}
|
||||
options.setState("inlineAliases", (prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(alias, part)
|
||||
return next
|
||||
})
|
||||
options.addContextFile(path, node?.selection)
|
||||
closeMention()
|
||||
queueMicrotask(() => {
|
||||
const caret = leading.length + mentionText.length + trailingSpacer.length
|
||||
input.setSelectionRange(caret, caret)
|
||||
syncMentionFromCaret(input)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
mentionResults,
|
||||
mentionItems,
|
||||
closeMention,
|
||||
syncMentionFromCaret,
|
||||
tryOpenMentionFromCaret,
|
||||
updateMentionPosition,
|
||||
handlePromptInput,
|
||||
handleMentionKeyDown,
|
||||
insertMention,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePromptScrollSync(options: ScrollSyncOptions) {
|
||||
let shouldAutoScroll = true
|
||||
|
||||
createEffect(() => {
|
||||
options.state.promptInput
|
||||
options.interim()
|
||||
queueMicrotask(() => {
|
||||
const input = options.getInputRef()
|
||||
const overlay = options.getOverlayRef()
|
||||
if (!input || !overlay) return
|
||||
if (!shouldAutoScroll) {
|
||||
overlay.scrollTop = input.scrollTop
|
||||
if (options.state.mentionOpen) options.updateMentionPosition(input)
|
||||
return
|
||||
}
|
||||
const maxInputScroll = input.scrollHeight - input.clientHeight
|
||||
const next = maxInputScroll > 0 ? maxInputScroll : 0
|
||||
input.scrollTop = next
|
||||
overlay.scrollTop = next
|
||||
if (options.state.mentionOpen) options.updateMentionPosition(input)
|
||||
})
|
||||
})
|
||||
|
||||
function handlePromptScroll(event: Event & { currentTarget: HTMLTextAreaElement }) {
|
||||
const target = event.currentTarget
|
||||
shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
|
||||
const overlay = options.getOverlayRef()
|
||||
if (overlay) overlay.scrollTop = target.scrollTop
|
||||
if (options.state.mentionOpen) options.updateMentionPosition(target)
|
||||
}
|
||||
|
||||
function resetScrollPosition() {
|
||||
shouldAutoScroll = true
|
||||
const input = options.getInputRef()
|
||||
const overlay = options.getOverlayRef()
|
||||
if (input) input.scrollTop = 0
|
||||
if (overlay) overlay.scrollTop = 0
|
||||
}
|
||||
|
||||
return {
|
||||
handlePromptScroll,
|
||||
resetScrollPosition,
|
||||
setAutoScroll: (value: boolean) => {
|
||||
shouldAutoScroll = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,581 +0,0 @@
|
|||
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Tooltip, Button, Icon, Select } from "@opencode-ai/ui"
|
||||
import { FileIcon, IconButton } from "@/ui"
|
||||
import { useLocal } from "@/context"
|
||||
import type { FileContext, LocalFile } from "@/context/local"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { composeDisplaySegments, createAttachmentDisplay, parsePrompt, registerCandidate } from "./prompt-form-helpers"
|
||||
import type {
|
||||
AttachmentCandidate,
|
||||
PromptAttachmentPart,
|
||||
PromptAttachmentSegment,
|
||||
PromptDisplaySegment,
|
||||
PromptSubmitValue,
|
||||
} from "./prompt-form-helpers"
|
||||
import { useMentionController, usePromptScrollSync, usePromptSpeech, type PromptFormState } from "./prompt-form-hooks"
|
||||
|
||||
interface PromptFormProps {
|
||||
class?: string
|
||||
classList?: Record<string, boolean>
|
||||
onSubmit: (prompt: PromptSubmitValue) => Promise<void> | void
|
||||
onOpenModelSelect: () => void
|
||||
onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void
|
||||
}
|
||||
|
||||
export default function PromptForm(props: PromptFormProps) {
|
||||
const local = useLocal()
|
||||
|
||||
const [state, setState] = createStore<PromptFormState>({
|
||||
promptInput: "",
|
||||
isDragOver: false,
|
||||
mentionOpen: false,
|
||||
mentionQuery: "",
|
||||
mentionRange: undefined,
|
||||
mentionIndex: 0,
|
||||
mentionAnchorOffset: { x: 0, y: 0 },
|
||||
inlineAliases: new Map<string, PromptAttachmentPart>(),
|
||||
})
|
||||
|
||||
const placeholderText = "Start typing or speaking..."
|
||||
|
||||
const {
|
||||
isSupported,
|
||||
isRecording,
|
||||
interim: interimTranscript,
|
||||
start: startSpeech,
|
||||
stop: stopSpeech,
|
||||
} = usePromptSpeech((updater) => setState("promptInput", updater))
|
||||
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
let overlayContainerRef: HTMLDivElement | undefined = undefined
|
||||
let mentionMeasureRef: HTMLDivElement | undefined = undefined
|
||||
|
||||
const attachmentLookup = createMemo(() => {
|
||||
const map = new Map<string, AttachmentCandidate>()
|
||||
const activeFile = local.context.active()
|
||||
if (activeFile) {
|
||||
registerCandidate(
|
||||
map,
|
||||
{
|
||||
origin: "active",
|
||||
path: activeFile.path,
|
||||
selection: activeFile.selection,
|
||||
display: createAttachmentDisplay(activeFile.path, activeFile.selection),
|
||||
},
|
||||
[activeFile.path, getFilename(activeFile.path)],
|
||||
)
|
||||
}
|
||||
for (const item of local.context.all()) {
|
||||
registerCandidate(
|
||||
map,
|
||||
{
|
||||
origin: "context",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
display: createAttachmentDisplay(item.path, item.selection),
|
||||
},
|
||||
[item.path, getFilename(item.path)],
|
||||
)
|
||||
}
|
||||
for (const [alias, part] of state.inlineAliases) {
|
||||
registerCandidate(
|
||||
map,
|
||||
{
|
||||
origin: part.origin,
|
||||
path: part.path,
|
||||
selection: part.selection,
|
||||
display: part.display ?? createAttachmentDisplay(part.path, part.selection),
|
||||
},
|
||||
[alias],
|
||||
)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const parsedPrompt = createMemo(() => parsePrompt(state.promptInput, attachmentLookup()))
|
||||
const baseParts = createMemo(() => parsedPrompt().parts)
|
||||
const attachmentSegments = createMemo<PromptAttachmentSegment[]>(() =>
|
||||
parsedPrompt().segments.filter((segment): segment is PromptAttachmentSegment => segment.kind === "attachment"),
|
||||
)
|
||||
|
||||
const {
|
||||
mentionResults,
|
||||
mentionItems,
|
||||
closeMention,
|
||||
syncMentionFromCaret,
|
||||
updateMentionPosition,
|
||||
handlePromptInput,
|
||||
handleMentionKeyDown,
|
||||
insertMention,
|
||||
} = useMentionController({
|
||||
state,
|
||||
setState,
|
||||
attachmentSegments,
|
||||
getInputRef: () => inputRef,
|
||||
getOverlayRef: () => overlayContainerRef,
|
||||
getMeasureRef: () => mentionMeasureRef,
|
||||
searchFiles: (query) => local.file.search(query),
|
||||
resolveFile: (path) => local.file.node(path) ?? undefined,
|
||||
addContextFile: (path, selection) =>
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path,
|
||||
selection,
|
||||
}),
|
||||
getActiveContext: () => local.context.active() ?? undefined,
|
||||
})
|
||||
|
||||
const { handlePromptScroll, resetScrollPosition } = usePromptScrollSync({
|
||||
state,
|
||||
getInputRef: () => inputRef,
|
||||
getOverlayRef: () => overlayContainerRef,
|
||||
interim: () => (isRecording() ? interimTranscript() : ""),
|
||||
updateMentionPosition,
|
||||
})
|
||||
|
||||
const displaySegments = createMemo<PromptDisplaySegment[]>(() => {
|
||||
const value = state.promptInput
|
||||
const segments = parsedPrompt().segments
|
||||
const interim = isRecording() ? interimTranscript() : ""
|
||||
return composeDisplaySegments(segments, value, interim)
|
||||
})
|
||||
|
||||
const hasDisplaySegments = createMemo(() => displaySegments().length > 0)
|
||||
|
||||
function handleAttachmentNavigation(
|
||||
event: KeyboardEvent & { currentTarget: HTMLTextAreaElement },
|
||||
direction: "left" | "right",
|
||||
) {
|
||||
const element = event.currentTarget
|
||||
const caret = element.selectionStart ?? 0
|
||||
const segments = attachmentSegments()
|
||||
if (direction === "left") {
|
||||
let match = segments.find((segment) => caret > segment.start && caret <= segment.end)
|
||||
if (!match && element.selectionStart !== element.selectionEnd) {
|
||||
match = segments.find(
|
||||
(segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end,
|
||||
)
|
||||
}
|
||||
if (!match) return false
|
||||
event.preventDefault()
|
||||
if (element.selectionStart === match.start && element.selectionEnd === match.end) {
|
||||
const next = Math.max(0, match.start)
|
||||
element.setSelectionRange(next, next)
|
||||
syncMentionFromCaret(element)
|
||||
return true
|
||||
}
|
||||
element.setSelectionRange(match.start, match.end)
|
||||
syncMentionFromCaret(element)
|
||||
return true
|
||||
}
|
||||
if (direction === "right") {
|
||||
let match = segments.find((segment) => caret >= segment.start && caret < segment.end)
|
||||
if (!match && element.selectionStart !== element.selectionEnd) {
|
||||
match = segments.find(
|
||||
(segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end,
|
||||
)
|
||||
}
|
||||
if (!match) return false
|
||||
event.preventDefault()
|
||||
if (element.selectionStart === match.start && element.selectionEnd === match.end) {
|
||||
const next = match.end
|
||||
element.setSelectionRange(next, next)
|
||||
syncMentionFromCaret(element)
|
||||
return true
|
||||
}
|
||||
element.setSelectionRange(match.start, match.end)
|
||||
syncMentionFromCaret(element)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function renderAttachmentChip(part: PromptAttachmentPart, _placeholder: string) {
|
||||
const display = part.display ?? createAttachmentDisplay(part.path, part.selection)
|
||||
return <span class="truncate max-w-[16ch] text-primary">@{display}</span>
|
||||
}
|
||||
|
||||
function renderTextSegment(value: string) {
|
||||
if (!value) return undefined
|
||||
return <span class="text-text">{value}</span>
|
||||
}
|
||||
|
||||
function handlePromptKeyDown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
|
||||
if (event.isComposing) return
|
||||
const target = event.currentTarget
|
||||
const key = event.key
|
||||
|
||||
const handled = handleMentionKeyDown({
|
||||
event,
|
||||
mentionItems,
|
||||
insertMention,
|
||||
})
|
||||
if (handled) return
|
||||
|
||||
if (!state.mentionOpen) {
|
||||
if (key === "ArrowLeft") {
|
||||
if (handleAttachmentNavigation(event, "left")) return
|
||||
}
|
||||
if (key === "ArrowRight") {
|
||||
if (handleAttachmentNavigation(event, "right")) return
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "ArrowLeft" || key === "ArrowRight" || key === "Home" || key === "End") {
|
||||
queueMicrotask(() => {
|
||||
syncMentionFromCaret(target)
|
||||
})
|
||||
}
|
||||
|
||||
if (key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
target.form?.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
const parts = baseParts()
|
||||
const text = parts
|
||||
.map((part) => {
|
||||
if (part.kind === "text") return part.value
|
||||
return `@${part.path}`
|
||||
})
|
||||
.join("")
|
||||
|
||||
const currentPrompt: PromptSubmitValue = {
|
||||
text,
|
||||
parts,
|
||||
}
|
||||
setState("promptInput", "")
|
||||
resetScrollPosition()
|
||||
if (inputRef) {
|
||||
inputRef.blur()
|
||||
}
|
||||
|
||||
await props.onSubmit(currentPrompt)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
props.onInputRefChange?.(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class={props.class} classList={props.classList}>
|
||||
<div
|
||||
class="w-full min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1 bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary
|
||||
transition-all duration-200"
|
||||
classList={{
|
||||
"shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(),
|
||||
"ring-2 ring-primary/60 bg-primary/5": state.isDragOver,
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
if (evt.dataTransfer?.types.includes("text/plain")) {
|
||||
evt.preventDefault()
|
||||
setState("isDragOver", true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
setState("isDragOver", false)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
if (evt.dataTransfer?.types.includes("text/plain")) {
|
||||
evt.preventDefault()
|
||||
evt.dataTransfer.dropEffect = "copy"
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
evt.preventDefault()
|
||||
setState("isDragOver", false)
|
||||
|
||||
const data = evt.dataTransfer?.getData("text/plain")
|
||||
if (data && data.startsWith("file:")) {
|
||||
const filePath = data.slice(5)
|
||||
const fileNode = local.file.node(filePath)
|
||||
if (fileNode) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={local.context.all().length > 0 || local.context.active()}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.context.active()}>
|
||||
<ActiveTabContextTag file={local.context.active()!} onClose={() => local.context.removeActive()} />
|
||||
</Show>
|
||||
<For each={local.context.all()}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.context.remove(file.key)} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref={(element) => {
|
||||
inputRef = element ?? undefined
|
||||
props.onInputRefChange?.(inputRef)
|
||||
}}
|
||||
value={state.promptInput}
|
||||
onInput={handlePromptInput}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onClick={(event) =>
|
||||
queueMicrotask(() => {
|
||||
syncMentionFromCaret(event.currentTarget)
|
||||
})
|
||||
}
|
||||
onSelect={(event) =>
|
||||
queueMicrotask(() => {
|
||||
syncMentionFromCaret(event.currentTarget)
|
||||
})
|
||||
}
|
||||
onBlur={(event) => {
|
||||
const next = event.relatedTarget as HTMLElement | null
|
||||
if (next && next.closest('[data-mention-popover="true"]')) return
|
||||
closeMention()
|
||||
}}
|
||||
onScroll={handlePromptScroll}
|
||||
placeholder={placeholderText}
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
class="relative w-full h-20 rounded-md px-0.5 resize-none overflow-y-auto
|
||||
bg-transparent text-transparent caret-text font-light text-base
|
||||
leading-relaxed focus:outline-none selection:bg-primary/20"
|
||||
></textarea>
|
||||
<div
|
||||
ref={(element) => {
|
||||
overlayContainerRef = element ?? undefined
|
||||
}}
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<PromptDisplayOverlay
|
||||
hasDisplaySegments={hasDisplaySegments()}
|
||||
displaySegments={displaySegments()}
|
||||
placeholder={placeholderText}
|
||||
renderAttachmentChip={renderAttachmentChip}
|
||||
renderTextSegment={renderTextSegment}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={(element) => {
|
||||
mentionMeasureRef = element ?? undefined
|
||||
}}
|
||||
class="pointer-events-none invisible absolute inset-0 whitespace-pre-wrap text-base font-light leading-relaxed px-0.5"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<MentionSuggestions
|
||||
open={state.mentionOpen}
|
||||
anchor={state.mentionAnchorOffset}
|
||||
loading={mentionResults.loading}
|
||||
items={mentionItems()}
|
||||
activeIndex={state.mentionIndex}
|
||||
onHover={(index) => setState("mentionIndex", index)}
|
||||
onSelect={insertMention}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="uppercase"
|
||||
/>
|
||||
<Button onClick={() => props.onOpenModelSelect()}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Show when={isSupported()}>
|
||||
<Tooltip value={isRecording() ? "Stop voice input" : "Start voice input"} placement="top">
|
||||
<IconButton
|
||||
onClick={async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (isRecording()) {
|
||||
stopSpeech()
|
||||
} else {
|
||||
startSpeech()
|
||||
}
|
||||
inputRef?.focus()
|
||||
}}
|
||||
classList={{
|
||||
"text-text-muted": !isRecording(),
|
||||
"text-error! animate-pulse": isRecording(),
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon name="mic" size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="text-background-panel! bg-primary rounded-full! hover:bg-primary/90 ml-0.5"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveTabContextTag = (props: { file: LocalFile; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FileTag = (props: { file: FileContext; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
<Show when={props.file.selection}>
|
||||
<span>
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
function PromptDisplayOverlay(props: {
|
||||
hasDisplaySegments: boolean
|
||||
displaySegments: PromptDisplaySegment[]
|
||||
placeholder: string
|
||||
renderAttachmentChip: (part: PromptAttachmentPart, placeholder: string) => JSX.Element
|
||||
renderTextSegment: (value: string) => JSX.Element | undefined
|
||||
}) {
|
||||
return (
|
||||
<div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left">
|
||||
<Show when={props.hasDisplaySegments} fallback={<span class="text-text-muted/70">{props.placeholder}</span>}>
|
||||
<For each={props.displaySegments}>
|
||||
{(segment) => {
|
||||
if (segment.kind === "text") {
|
||||
return props.renderTextSegment(segment.value)
|
||||
}
|
||||
if (segment.kind === "attachment") {
|
||||
return props.renderAttachmentChip(segment.part, segment.source)
|
||||
}
|
||||
return (
|
||||
<span class="text-text-muted/60 italic">
|
||||
{segment.leadingSpace ? ` ${segment.value}` : segment.value}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MentionSuggestions(props: {
|
||||
open: boolean
|
||||
anchor: { x: number; y: number }
|
||||
loading: boolean
|
||||
items: string[]
|
||||
activeIndex: number
|
||||
onHover: (index: number) => void
|
||||
onSelect: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Popover open={props.open} modal={false} gutter={8} placement="bottom-start">
|
||||
<Popover.Trigger class="hidden" />
|
||||
<Popover.Anchor
|
||||
class="pointer-events-none absolute top-0 left-0 w-0 h-0"
|
||||
style={{ transform: `translate(${props.anchor.x}px, ${props.anchor.y}px)` }}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-mention-popover="true"
|
||||
class="z-50 w-72 max-h-60 overflow-y-auto rounded-md border border-border-subtle/40 bg-background-panel shadow-[0_10px_30px_rgba(0,0,0,0.35)] focus:outline-none"
|
||||
>
|
||||
<div class="py-1">
|
||||
<Show when={props.loading}>
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs text-text-muted">
|
||||
<Icon name="refresh" size={12} class="animate-spin" />
|
||||
<span>Searching…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.loading && props.items.length === 0}>
|
||||
<div class="px-3 py-2 text-xs text-text-muted/80">No matching files</div>
|
||||
</Show>
|
||||
<For each={props.items}>
|
||||
{(path, indexAccessor) => {
|
||||
const index = indexAccessor()
|
||||
const dir = getDirectory(path)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => props.onHover(index)}
|
||||
onClick={() => props.onSelect(path)}
|
||||
class="w-full px-3 py-2 flex items-center gap-2 rounded-md text-left text-xs transition-colors"
|
||||
classList={{
|
||||
"bg-background-element text-text": index === props.activeIndex,
|
||||
"text-text-muted": index !== props.activeIndex,
|
||||
}}
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="size-3 shrink-0" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="truncate">{getFilename(path)}</span>
|
||||
{dir && <span class="truncate text-text-muted/70">{dir}</span>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export type {
|
||||
PromptAttachmentPart,
|
||||
PromptAttachmentSegment,
|
||||
PromptContentPart,
|
||||
PromptDisplaySegment,
|
||||
PromptSubmitValue,
|
||||
} from "./prompt-form-helpers"
|
||||
|
|
@ -1,63 +1,74 @@
|
|||
import { createEffect, on, Component, createMemo, Show } from "solid-js"
|
||||
import { useLocal } from "@/context"
|
||||
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@/ui"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { TextSelection } from "@/context/local"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
interface TextPart {
|
||||
type: "text"
|
||||
interface PartBase {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface AttachmentPart {
|
||||
type: "attachment"
|
||||
fileId: string
|
||||
name: string
|
||||
interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | AttachmentPart
|
||||
|
||||
export interface AttachmentToAdd {
|
||||
id: string
|
||||
name: string
|
||||
interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
type AddAttachmentCallback = (attachment: AttachmentToAdd) => void
|
||||
|
||||
export interface PopoverState {
|
||||
isOpen: boolean
|
||||
searchQuery: string
|
||||
addAttachment: AddAttachmentCallback
|
||||
}
|
||||
export type ContentPart = TextPart | FileAttachmentPart
|
||||
|
||||
interface PromptInputProps {
|
||||
onSubmit: (parts: ContentPart[]) => void
|
||||
onShowAttachments?: (state: PopoverState | null) => void
|
||||
class?: string
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let editorRef: HTMLDivElement | undefined
|
||||
const local = useLocal()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
const defaultParts = [{ type: "text", content: "" } as const]
|
||||
const [store, setStore] = createStore<{
|
||||
contentParts: ContentPart[]
|
||||
popover: {
|
||||
isOpen: boolean
|
||||
searchQuery: string
|
||||
}
|
||||
popoverIsOpen: boolean
|
||||
}>({
|
||||
contentParts: defaultParts,
|
||||
popover: {
|
||||
isOpen: false,
|
||||
searchQuery: "",
|
||||
},
|
||||
popoverIsOpen: false,
|
||||
})
|
||||
|
||||
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
|
||||
createEffect(() => {
|
||||
if (isFocused()) {
|
||||
handleInput()
|
||||
} else {
|
||||
setStore("popoverIsOpen", false)
|
||||
}
|
||||
})
|
||||
|
||||
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
||||
items: local.file.search,
|
||||
key: (x) => x,
|
||||
onSelect: (path) => {
|
||||
if (!path) return
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path) })
|
||||
setStore("popoverIsOpen", false)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => store.contentParts,
|
||||
(currentParts) => {
|
||||
if (!editorRef) return
|
||||
const domParts = parseFromDOM()
|
||||
if (isEqual(currentParts, domParts)) return
|
||||
|
||||
|
|
@ -70,14 +81,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
editorRef.innerHTML = ""
|
||||
currentParts.forEach((part) => {
|
||||
if (part.type === "text") {
|
||||
editorRef!.appendChild(document.createTextNode(part.content))
|
||||
} else if (part.type === "attachment") {
|
||||
editorRef.appendChild(document.createTextNode(part.content))
|
||||
} else if (part.type === "file") {
|
||||
const pill = document.createElement("span")
|
||||
pill.textContent = `@${part.name}`
|
||||
pill.className = "attachment-pill"
|
||||
pill.setAttribute("data-file-id", part.fileId)
|
||||
pill.textContent = part.content
|
||||
pill.setAttribute("data-type", "file")
|
||||
pill.setAttribute("data-path", part.path)
|
||||
pill.setAttribute("contenteditable", "false")
|
||||
editorRef!.appendChild(pill)
|
||||
pill.style.userSelect = "text"
|
||||
pill.style.cursor = "default"
|
||||
editorRef.appendChild(pill)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -88,30 +101,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (store.popover.isOpen) {
|
||||
props.onShowAttachments?.({
|
||||
isOpen: true,
|
||||
searchQuery: store.popover.searchQuery,
|
||||
addAttachment: addAttachment,
|
||||
})
|
||||
} else {
|
||||
props.onShowAttachments?.(null)
|
||||
}
|
||||
})
|
||||
|
||||
const parseFromDOM = (): ContentPart[] => {
|
||||
if (!editorRef) return []
|
||||
const newParts: ContentPart[] = []
|
||||
editorRef.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) {
|
||||
newParts.push({
|
||||
type: "attachment",
|
||||
fileId: (node as HTMLElement).dataset.fileId!,
|
||||
name: node.textContent!.substring(1),
|
||||
})
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
|
||||
switch ((node as HTMLElement).dataset.type) {
|
||||
case "file":
|
||||
newParts.push({
|
||||
type: "file",
|
||||
path: (node as HTMLElement).dataset.path!,
|
||||
content: node.textContent!,
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
if (newParts.length === 0) newParts.push(...defaultParts)
|
||||
|
|
@ -120,96 +126,234 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
const handleInput = () => {
|
||||
const rawParts = parseFromDOM()
|
||||
const cursorPosition = getCursorPosition(editorRef!)
|
||||
const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const rawText = rawParts.map((p) => p.content).join("")
|
||||
|
||||
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
||||
if (atMatch) {
|
||||
setStore("popover", { isOpen: true, searchQuery: atMatch[1] })
|
||||
} else if (store.popover.isOpen) {
|
||||
setStore("popover", "isOpen", false)
|
||||
onInput(atMatch[1])
|
||||
setStore("popoverIsOpen", true)
|
||||
} else if (store.popoverIsOpen) {
|
||||
setStore("popoverIsOpen", false)
|
||||
}
|
||||
|
||||
setStore("contentParts", rawParts)
|
||||
}
|
||||
|
||||
const addAttachment: AddAttachmentCallback = (attachment) => {
|
||||
const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("")
|
||||
const cursorPosition = getCursorPosition(editorRef!)
|
||||
|
||||
const addPart = (part: ContentPart) => {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const rawText = store.contentParts.map((p) => p.content).join("")
|
||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||
|
||||
if (!atMatch) return
|
||||
|
||||
const startIndex = atMatch.index!
|
||||
const endIndex = cursorPosition
|
||||
|
||||
// Create new structured content
|
||||
const newParts: ContentPart[] = []
|
||||
const textBeforeTrigger = rawText.substring(0, startIndex)
|
||||
if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger })
|
||||
const {
|
||||
parts: nextParts,
|
||||
cursorIndex,
|
||||
cursorOffset,
|
||||
inserted,
|
||||
} = store.contentParts.reduce(
|
||||
(acc, item) => {
|
||||
if (acc.inserted) {
|
||||
acc.parts.push(item)
|
||||
acc.runningIndex += item.content.length
|
||||
return acc
|
||||
}
|
||||
|
||||
newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name })
|
||||
const nextIndex = acc.runningIndex + item.content.length
|
||||
if (nextIndex <= startIndex) {
|
||||
acc.parts.push(item)
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
}
|
||||
|
||||
// Add a space after the pill for better UX
|
||||
newParts.push({ type: "text", content: " " })
|
||||
if (item.type !== "text") {
|
||||
acc.parts.push(item)
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
}
|
||||
|
||||
const textAfterCursor = rawText.substring(cursorPosition)
|
||||
if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor })
|
||||
const headLength = Math.max(0, startIndex - acc.runningIndex)
|
||||
const tailLength = Math.max(0, endIndex - acc.runningIndex)
|
||||
const head = item.content.slice(0, headLength)
|
||||
const tail = item.content.slice(tailLength)
|
||||
|
||||
setStore("contentParts", newParts)
|
||||
setStore("popover", "isOpen", false)
|
||||
if (head) acc.parts.push({ type: "text", content: head })
|
||||
|
||||
acc.parts.push(part)
|
||||
|
||||
const rest = /^\s/.test(tail) ? tail : ` ${tail}`
|
||||
if (rest) {
|
||||
acc.cursorIndex = acc.parts.length
|
||||
acc.cursorOffset = Math.min(1, rest.length)
|
||||
acc.parts.push({ type: "text", content: rest })
|
||||
}
|
||||
|
||||
acc.inserted = true
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
},
|
||||
{
|
||||
parts: [] as ContentPart[],
|
||||
runningIndex: 0,
|
||||
inserted: false,
|
||||
cursorIndex: null as number | null,
|
||||
cursorOffset: 0,
|
||||
},
|
||||
)
|
||||
|
||||
if (!inserted || cursorIndex === null) return
|
||||
|
||||
setStore("contentParts", nextParts)
|
||||
setStore("popoverIsOpen", false)
|
||||
|
||||
// Set cursor position after the newly added pill + space
|
||||
// We need to wait for the DOM to update
|
||||
queueMicrotask(() => {
|
||||
setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1)
|
||||
const node = editorRef.childNodes[cursorIndex]
|
||||
if (node && node.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
const length = node.textContent ? node.textContent.length : 0
|
||||
const offset = cursorOffset > length ? length : cursorOffset
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
// In a real implementation, you'd prevent default and delegate this to the popover
|
||||
console.log("Key press delegated to popover:", event.key)
|
||||
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (store.contentParts.length > 0) {
|
||||
props.onSubmit([...store.contentParts])
|
||||
setStore("contentParts", defaultParts)
|
||||
}
|
||||
handleSubmit(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (store.contentParts.length > 0) {
|
||||
props.onSubmit([...store.contentParts])
|
||||
setStore("contentParts", defaultParts)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
||||
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<div class="p-3" />
|
||||
<div class="relative">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contenteditable="true"
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"w-full p-3 text-sm focus:outline-none": true,
|
||||
}}
|
||||
/>
|
||||
<Show when={isEmpty()}>
|
||||
<div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none">
|
||||
Plan and build anything
|
||||
<div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
|
||||
<Show when={store.popoverIsOpen}>
|
||||
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
||||
<For each={flat()}>
|
||||
{(i) => (
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
"bg-surface-raised-base-hover": active() === i,
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}/
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
||||
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<div class="relative max-h-[240px] overflow-y-auto">
|
||||
<div
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
contenteditable="true"
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||
}}
|
||||
/>
|
||||
<Show when={isEmpty()}>
|
||||
<div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
|
||||
Plan and build anything
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
/>
|
||||
<SelectDialog
|
||||
title="Select model"
|
||||
placeholder="Search models"
|
||||
emptyMessage="No model results"
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={local.model.list()}
|
||||
current={local.model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
|
||||
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
|
||||
return order.indexOf(aProvider) - order.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||
trigger={
|
||||
<Button as="div" variant="ghost">
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center justify-between gap-x-3">
|
||||
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
|
||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
|
||||
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
|
||||
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
|
||||
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
|
||||
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!i.cost || i.cost?.input === 0}>
|
||||
<div class="overflow-hidden text-12-medium text-text-strong">Free</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</SelectDialog>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-3" />
|
||||
<IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -223,7 +367,7 @@ function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
|
|||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) {
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -241,24 +385,48 @@ function getCursorPosition(parent: HTMLElement): number {
|
|||
}
|
||||
|
||||
function setCursorPosition(parent: HTMLElement, position: number) {
|
||||
let child = parent.firstChild
|
||||
let offset = position
|
||||
while (child) {
|
||||
if (offset > child.textContent!.length) {
|
||||
offset -= child.textContent!.length
|
||||
child = child.nextSibling
|
||||
} else {
|
||||
try {
|
||||
const range = document.createRange()
|
||||
const sel = window.getSelection()
|
||||
range.setStart(child, offset)
|
||||
range.collapse(true)
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
} catch (e) {
|
||||
console.error("Failed to set cursor position.", e)
|
||||
}
|
||||
let remaining = position
|
||||
let node = parent.firstChild
|
||||
while (node) {
|
||||
const length = node.textContent ? node.textContent.length : 0
|
||||
const isText = node.nodeType === Node.TEXT_NODE
|
||||
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
||||
|
||||
if (isText && remaining <= length) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
return
|
||||
}
|
||||
|
||||
if (isFile && remaining <= length) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
return
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
node = node.nextSibling
|
||||
}
|
||||
|
||||
const fallbackRange = document.createRange()
|
||||
const fallbackSelection = window.getSelection()
|
||||
const last = parent.lastChild
|
||||
if (last && last.nodeType === Node.TEXT_NODE) {
|
||||
const len = last.textContent ? last.textContent.length : 0
|
||||
fallbackRange.setStart(last, len)
|
||||
}
|
||||
if (!last || last.nodeType !== Node.TEXT_NODE) {
|
||||
fallbackRange.selectNodeContents(parent)
|
||||
}
|
||||
fallbackRange.collapse(false)
|
||||
fallbackSelection?.removeAllRanges()
|
||||
fallbackSelection?.addRange(fallbackRange)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Icon } from "@opencode-ai/ui"
|
||||
import { IconButton } from "@/ui"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
||||
import { createList } from "solid-list"
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
interface SelectDialogProps<T> {
|
||||
items: T[] | ((filter: string) => Promise<T[]>)
|
||||
key: (item: T) => string
|
||||
render: (item: T) => JSX.Element
|
||||
filter?: string[]
|
||||
current?: T
|
||||
placeholder?: string
|
||||
groupBy?: (x: T) => string
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
mouseActive: false,
|
||||
})
|
||||
|
||||
const [grouped] = createResource(
|
||||
() => store.filter,
|
||||
async (filter) => {
|
||||
const needle = filter.toLowerCase()
|
||||
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||
const result = pipe(
|
||||
all,
|
||||
(x) => {
|
||||
if (!needle) return x
|
||||
if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
||||
}
|
||||
return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
|
||||
},
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
// mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, items: v })),
|
||||
)
|
||||
return result
|
||||
},
|
||||
)
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped() || [],
|
||||
flatMap((x) => x.items),
|
||||
)
|
||||
})
|
||||
const list = createList({
|
||||
items: () => flat().map(props.key),
|
||||
initialActive: props.current ? props.key(props.current) : undefined,
|
||||
loop: true,
|
||||
})
|
||||
const resetSelection = () => {
|
||||
const all = flat()
|
||||
if (all.length === 0) return
|
||||
list.setActive(props.key(all[0]))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
resetSelection()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
if (store.mouseActive || all.length === 0) return
|
||||
if (list.active() === props.key(all[0])) {
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setStore("filter", value)
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
const handleSelect = (item: T) => {
|
||||
props.onSelect?.(item)
|
||||
props.onClose?.()
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
setStore("mouseActive", false)
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = flat().find((x) => props.key(x) === list.active())
|
||||
if (selected) handleSelect(selected)
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onClose?.()
|
||||
} else {
|
||||
list.onKeyDown(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
|
||||
<Dialog.Content
|
||||
class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
|
||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
||||
bg-background border border-border-subtle/30 rounded-lg z-[101]
|
||||
max-h-[60vh] flex flex-col"
|
||||
>
|
||||
<div class="border-b border-border-subtle/30">
|
||||
<div class="relative">
|
||||
<Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
|
||||
<input
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onInput={(e) => handleInput(e.currentTarget.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={props.placeholder}
|
||||
class="w-full pl-10 pr-4 py-2 rounded-t-md
|
||||
text-sm text-text placeholder-text-muted/70
|
||||
focus:outline-none"
|
||||
autofocus
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{/* <Show when={fileResults.loading && mode() === "files"}>
|
||||
<div class="text-text-muted">
|
||||
<Icon name="refresh" size={14} class="animate-spin" />
|
||||
</div>
|
||||
</Show> */}
|
||||
<Show when={store.filter}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="text-text-muted hover:text-text"
|
||||
onClick={() => {
|
||||
setStore("filter", "")
|
||||
resetSelection()
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
|
||||
<Show
|
||||
when={flat().length > 0}
|
||||
fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{(group) => (
|
||||
<>
|
||||
<Show when={group.category}>
|
||||
<div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
|
||||
{group.category}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="p-2">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
data-key={props.key(item)}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseMove={() => {
|
||||
setStore("mouseActive", true)
|
||||
list.setActive(props.key(item))
|
||||
}}
|
||||
classList={{
|
||||
"w-full px-3 py-2 flex items-center gap-3": true,
|
||||
"rounded-md text-left transition-colors group": true,
|
||||
"bg-background-element": props.key(item) === list.active(),
|
||||
}}
|
||||
>
|
||||
{props.render(item)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
|
||||
<div class="flex items-center gap-5">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
|
||||
ESC
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<span>{`${flat().length} results`}</span>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { useLocal, useSync } from "@/context"
|
||||
import { Icon, Tooltip } from "@opencode-ai/ui"
|
||||
import { Collapsible } from "@/ui"
|
||||
import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk"
|
||||
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
createSignal,
|
||||
onMount,
|
||||
For,
|
||||
Match,
|
||||
splitProps,
|
||||
|
|
@ -67,7 +66,7 @@ function ReadToolPart(props: { part: ToolPart }) {
|
|||
{(state) => {
|
||||
const path = state().input["filePath"] as string
|
||||
return (
|
||||
<Part class="cursor-pointer" onClick={() => local.file.open(path)}>
|
||||
<Part onClick={() => local.file.open(path)}>
|
||||
<span class="">Read</span> {getFilename(path)}
|
||||
</Part>
|
||||
)
|
||||
|
|
@ -253,7 +252,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
case "patch":
|
||||
return false
|
||||
case "text":
|
||||
return !part.synthetic
|
||||
return !part.synthetic && part.text.trim()
|
||||
case "reasoning":
|
||||
return part.text.trim()
|
||||
case "tool":
|
||||
|
|
@ -270,8 +269,17 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
}
|
||||
}
|
||||
|
||||
const hasValidParts = (message: Message) => {
|
||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
||||
}
|
||||
|
||||
const hasTextPart = (message: Message) => {
|
||||
return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
||||
}
|
||||
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||
const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
|
|
@ -386,7 +394,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<div class="py-1.5 px-10 flex justify-end items-center self-stretch">
|
||||
<div class="py-1.5 px-6 flex justify-end items-center self-stretch">
|
||||
<div class="flex items-center gap-6">
|
||||
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
|
||||
<Show when={context()}>
|
||||
|
|
@ -397,11 +405,16 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul role="list" class="flex flex-col gap-6 items-start self-stretch px-10 pt-2 pb-6">
|
||||
<For each={messages()}>
|
||||
<ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1">
|
||||
<For each={messagesWithValidParts()}>
|
||||
{(message) => (
|
||||
<div class="flex flex-col gap-1 justify-center items-start self-stretch">
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col gap-1 justify-center items-start self-stretch": true,
|
||||
"mt-6": hasTextPart(message),
|
||||
}}
|
||||
>
|
||||
<For each={sync.data.part[message.id]?.filter(valid) ?? []}>
|
||||
{(part) => (
|
||||
<li class="group/li">
|
||||
<Switch fallback={<div class="">{part.type}</div>}>
|
||||
|
|
@ -449,9 +462,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
<Collapsible defaultOpen={false}>
|
||||
<Collapsible.Trigger>
|
||||
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||
<Icon name="file-code" size={16} />
|
||||
<Icon name="file-code" />
|
||||
<span>Raw Session Data</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-5">
|
||||
|
|
@ -460,9 +473,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<Icon name="file-code" />
|
||||
<span>session</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
|
|
@ -477,9 +490,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<Icon name="file-code" />
|
||||
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
|
|
@ -493,9 +506,9 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" size={16} />
|
||||
<Icon name="file-code" />
|
||||
<span>{part.type}</span>
|
||||
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { Button, Icon, List, Tooltip } from "@opencode-ai/ui"
|
||||
import { FileIcon, IconButton } from "@/ui"
|
||||
import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import EditorPane from "@/components/editor-pane"
|
||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { SelectDialog } from "@/components/select-dialog"
|
||||
import { For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useSync, useSDK, useLocal } from "@/context"
|
||||
import type { LocalFile, TextSelection } from "@/context/local"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
import { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export default function Page() {
|
||||
|
|
@ -22,8 +20,7 @@ export default function Page() {
|
|||
modelSelectOpen: false,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
let inputRef!: HTMLDivElement
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
|
|
@ -50,7 +47,7 @@ export default function Page() {
|
|||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (event.key === "Escape") {
|
||||
// inputRef?.blur()
|
||||
inputRef?.blur()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -77,7 +74,7 @@ export default function Page() {
|
|||
}
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||
// inputRef?.focus()
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,9 +101,7 @@ export default function Page() {
|
|||
}
|
||||
}
|
||||
|
||||
const handlePromptSubmit2 = () => {}
|
||||
|
||||
const handlePromptSubmit = async (prompt: PromptSubmitValue) => {
|
||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||
const existingSession = local.session.active()
|
||||
let session = existingSession
|
||||
if (!session) {
|
||||
|
|
@ -134,6 +129,7 @@ export default function Page() {
|
|||
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
|
||||
const text = parts.map((part) => part.content).join("")
|
||||
const attachments = new Map<string, SubmissionAttachment>()
|
||||
|
||||
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
|
||||
|
|
@ -147,30 +143,27 @@ export default function Page() {
|
|||
})
|
||||
}
|
||||
|
||||
const promptAttachments = prompt.parts.filter(
|
||||
(part): part is Extract<PromptContentPart, { kind: "attachment" }> => part.kind === "attachment",
|
||||
)
|
||||
|
||||
const promptAttachments = parts.filter((part) => part.type === "file")
|
||||
for (const part of promptAttachments) {
|
||||
registerAttachment(part.path, part.selection, part.display)
|
||||
registerAttachment(part.path, part.selection, part.content)
|
||||
}
|
||||
|
||||
const activeFile = local.context.active()
|
||||
if (activeFile) {
|
||||
registerAttachment(
|
||||
activeFile.path,
|
||||
activeFile.selection,
|
||||
activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||
)
|
||||
}
|
||||
// const activeFile = local.context.active()
|
||||
// if (activeFile) {
|
||||
// registerAttachment(
|
||||
// activeFile.path,
|
||||
// activeFile.selection,
|
||||
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
for (const contextFile of local.context.all()) {
|
||||
registerAttachment(
|
||||
contextFile.path,
|
||||
contextFile.selection,
|
||||
formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||
)
|
||||
}
|
||||
// for (const contextFile of local.context.all()) {
|
||||
// registerAttachment(
|
||||
// contextFile.path,
|
||||
// contextFile.selection,
|
||||
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
|
|
@ -205,7 +198,7 @@ export default function Page() {
|
|||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt.text,
|
||||
text,
|
||||
},
|
||||
...attachmentParts,
|
||||
],
|
||||
|
|
@ -213,16 +206,10 @@ export default function Page() {
|
|||
})
|
||||
}
|
||||
|
||||
const plus = (
|
||||
<IconButton
|
||||
class="text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
onClick={() => setStore("fileSelectOpen", true)}
|
||||
>
|
||||
<Icon name="plus" size={12} />
|
||||
</IconButton>
|
||||
)
|
||||
const handleNewSession = () => {
|
||||
local.session.setActive(undefined)
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative h-screen flex flex-col">
|
||||
|
|
@ -234,7 +221,8 @@ export default function Page() {
|
|||
</div>
|
||||
<div class="flex flex-col items-start gap-4 self-stretch flex-1">
|
||||
<div class="px-3 py-1.5 w-full">
|
||||
<Button class="w-full" size="large">
|
||||
<Button class="w-full" size="large" onClick={handleNewSession}>
|
||||
<Icon name="plus" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -268,25 +256,30 @@ export default function Page() {
|
|||
</List>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative grid grid-cols-2 bg-background-base">
|
||||
<div class="relative grid grid-cols-2 bg-background-base w-full">
|
||||
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
|
||||
<Show when={local.session.active()}>
|
||||
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
|
||||
<EditorPane onFileClick={handleFileClick} />
|
||||
<Show when={local.session.active()}>
|
||||
<EditorPane onFileClick={handleFileClick} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="absolute bottom-4 inset-x-0 p-2 flex flex-col justify-center items-center z-50">
|
||||
<PromptInput onSubmit={handlePromptSubmit2} />
|
||||
{/* <PromptForm */}
|
||||
{/* class="w-2xl" */}
|
||||
{/* onSubmit={handlePromptSubmit} */}
|
||||
{/* onOpenModelSelect={() => setStore("modelSelectOpen", true)} */}
|
||||
{/* onInputRefChange={(element: HTMLTextAreaElement | undefined) => { */}
|
||||
{/* inputRef = element ?? undefined */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true,
|
||||
"bottom-8": !!local.session.active(),
|
||||
"bottom-1/2 translate-y-1/2": !local.session.active(),
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
onSubmit={handlePromptSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
|
|
@ -302,7 +295,7 @@ export default function Page() {
|
|||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
|
|
@ -318,59 +311,16 @@ export default function Page() {
|
|||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Show when={store.modelSelectOpen}>
|
||||
<SelectDialog
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={local.model.list()}
|
||||
current={local.model.current()}
|
||||
render={(i) => (
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
|
||||
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{i.id}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
|
||||
<Tooltip forceMount={false} value="Reasoning">
|
||||
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
|
||||
</Tooltip>
|
||||
<Tooltip forceMount={false} value="Tools">
|
||||
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
|
||||
</Tooltip>
|
||||
<Tooltip forceMount={false} value="Attachments">
|
||||
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
|
||||
</Tooltip>
|
||||
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(i.limit.context)}
|
||||
</div>
|
||||
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
|
||||
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
|
||||
<Switch fallback="FREE">
|
||||
<Match when={i.cost?.input > 10}>$$$</Match>
|
||||
<Match when={i.cost?.input > 1}>$$</Match>
|
||||
<Match when={i.cost?.input > 0.1}>$</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
filter={["provider.name", "name", "id"]}
|
||||
groupBy={(x) => x.provider.name}
|
||||
onClose={() => setStore("modelSelectOpen", false)}
|
||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.fileSelectOpen}>
|
||||
<SelectDialog
|
||||
defaultOpen
|
||||
title="Select file"
|
||||
items={local.file.search}
|
||||
key={(x) => x}
|
||||
render={(i) => (
|
||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
|
|
@ -382,9 +332,7 @@ export default function Page() {
|
|||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
onClose={() => setStore("fileSelectOpen", false)}
|
||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
||||
/>
|
||||
</SelectDialog>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function CollapsibleTrigger(props: CollapsibleTriggerProps) {
|
|||
return (
|
||||
<KobalteCollapsible.Trigger
|
||||
classList={{
|
||||
"w-full group/collapsible cursor-pointer": true,
|
||||
"w-full group/collapsible": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import { Button as KobalteButton } from "@kobalte/core/button"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, JSX } from "solid-js"
|
||||
|
||||
export interface IconButtonProps extends ComponentProps<typeof KobalteButton> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost"
|
||||
size?: "xs" | "sm" | "md" | "lg"
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function IconButton(props: IconButtonProps) {
|
||||
const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
return (
|
||||
<KobalteButton
|
||||
classList={{
|
||||
...(local.classList || {}),
|
||||
"inline-flex items-center justify-center rounded-md font-medium cursor-pointer": true,
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2": true,
|
||||
"disabled:pointer-events-none disabled:opacity-50": true,
|
||||
"bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50":
|
||||
(local.variant || "primary") === "primary",
|
||||
"bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50":
|
||||
local.variant === "secondary",
|
||||
"border border-border bg-transparent text-text hover:bg-background-panel": local.variant === "outline",
|
||||
"focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted":
|
||||
local.variant === "outline",
|
||||
"text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted":
|
||||
local.variant === "ghost",
|
||||
"h-5 w-5 text-xs": local.size === "xs",
|
||||
"h-8 w-8 text-sm": local.size === "sm",
|
||||
"h-10 w-10 text-sm": (local.size || "md") === "md",
|
||||
"h-12 w-12 text-base": local.size === "lg",
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,4 +5,3 @@ export {
|
|||
type CollapsibleContentProps,
|
||||
} from "./collapsible"
|
||||
export { FileIcon, type FileIconProps } from "./file-icon"
|
||||
export { IconButton, type IconButtonProps } from "./icon-button"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { EOL } from "os"
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
|
@ -13,7 +14,7 @@ const FileSearchCommand = cmd({
|
|||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await File.search({ query: args.query })
|
||||
console.log(results.join("\n"))
|
||||
console.log(results.join(EOL))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { EOL } from "os"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
|
|
@ -48,7 +49,7 @@ const FilesCommand = cmd({
|
|||
files.push(file)
|
||||
if (args.limit && files.length >= args.limit) break
|
||||
}
|
||||
console.log(files.join("\n"))
|
||||
console.log(files.join(EOL))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { cmd } from "./cmd"
|
|||
import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
|
||||
export const ExportCommand = cmd({
|
||||
command: "export [sessionID]",
|
||||
|
|
@ -67,7 +68,7 @@ export const ExportCommand = cmd({
|
|||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(exportData, null, 2))
|
||||
process.stdout.write("\n")
|
||||
process.stdout.write(EOL)
|
||||
} catch (error) {
|
||||
UI.error(`Session not found: ${sessionID!}`)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Identifier } from "../../id/id"
|
|||
import { Agent } from "../../agent/agent"
|
||||
import { Command } from "../../command"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { EOL } from "os"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
|
|
@ -194,13 +195,12 @@ export const RunCommand = cmd({
|
|||
sessionID: session?.id,
|
||||
...data,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(jsonEvent) + "\n")
|
||||
process.stdout.write(JSON.stringify(jsonEvent) + EOL)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let text = ""
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
|
|
@ -232,15 +232,14 @@ export const RunCommand = cmd({
|
|||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
text = part.text
|
||||
const text = part.text
|
||||
const isPiped = !process.stdout.isTTY
|
||||
|
||||
if (part.time?.end) {
|
||||
if (outputJsonEvent("text", { part })) return
|
||||
UI.empty()
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
if (!isPiped) UI.println()
|
||||
process.stdout.write((isPiped ? text : UI.markdown(text)) + EOL)
|
||||
if (!isPiped) UI.println()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -254,13 +253,13 @@ export const RunCommand = cmd({
|
|||
if ("data" in error && error.data && "message" in error.data) {
|
||||
err = error.data.message
|
||||
}
|
||||
errorMsg = errorMsg ? errorMsg + "\n" + err : err
|
||||
errorMsg = errorMsg ? errorMsg + EOL + err : err
|
||||
|
||||
if (outputJsonEvent("error", { error })) return
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
const result = await (async () => {
|
||||
await (async () => {
|
||||
if (args.command) {
|
||||
return await SessionPrompt.command({
|
||||
messageID,
|
||||
|
|
@ -289,15 +288,6 @@ export const RunCommand = cmd({
|
|||
],
|
||||
})
|
||||
})()
|
||||
|
||||
const isPiped = !process.stdout.isTTY
|
||||
if (isPiped) {
|
||||
const match = result.parts.findLast((x: any) => x.type === "text") as any
|
||||
if (outputJsonEvent("text", { text: match })) return
|
||||
if (match) process.stdout.write(UI.markdown(match.text))
|
||||
if (errorMsg) process.stdout.write(errorMsg)
|
||||
}
|
||||
UI.empty()
|
||||
if (errorMsg) process.exit(1)
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export namespace Flag {
|
|||
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL_WATCHER = truthy("OPENCODE_EXPERIMENTAL_WATCHER")
|
||||
export const OPENCODE_EXPERIMENTAL_TURN_SUMMARY = truthy("OPENCODE_EXPERIMENTAL_TURN_SUMMARY")
|
||||
export const OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP = truthy("OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP")
|
||||
|
||||
function truthy(key: string) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { AttachCommand } from "./cli/cmd/tui/attach"
|
|||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
|
|
@ -132,7 +133,7 @@ try {
|
|||
const formatted = FormatError(e)
|
||||
if (formatted) UI.error(formatted)
|
||||
if (formatted === undefined) {
|
||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details\n")
|
||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
||||
console.error(e)
|
||||
}
|
||||
process.exitCode = 1
|
||||
|
|
|
|||
|
|
@ -95,7 +95,14 @@ export namespace Provider {
|
|||
|
||||
switch (regionPrefix) {
|
||||
case "us": {
|
||||
const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m))
|
||||
const modelRequiresPrefix = [
|
||||
"nova-micro",
|
||||
"nova-lite",
|
||||
"nova-pro",
|
||||
"nova-premier",
|
||||
"claude",
|
||||
"deepseek"
|
||||
].some((m) => modelID.includes(m))
|
||||
const isGovCloud = region.startsWith("us-gov")
|
||||
if (modelRequiresPrefix && !isGovCloud) {
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
|
|
@ -121,9 +128,10 @@ export namespace Provider {
|
|||
}
|
||||
case "ap": {
|
||||
const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
|
||||
if (isAustraliaRegion && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)) {
|
||||
if (
|
||||
isAustraliaRegion &&
|
||||
["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
|
||||
) {
|
||||
regionPrefix = "au"
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
} else {
|
||||
|
|
@ -273,31 +281,31 @@ export namespace Provider {
|
|||
cost:
|
||||
!model.cost && !existing?.cost
|
||||
? {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
}
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
}
|
||||
: {
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
...existing?.cost,
|
||||
...model.cost,
|
||||
},
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
...existing?.cost,
|
||||
...model.cost,
|
||||
},
|
||||
options: {
|
||||
...existing?.options,
|
||||
...model.options,
|
||||
},
|
||||
limit: model.limit ??
|
||||
existing?.limit ?? {
|
||||
context: 0,
|
||||
output: 0,
|
||||
},
|
||||
context: 0,
|
||||
output: 0,
|
||||
},
|
||||
modalities: model.modalities ??
|
||||
existing?.modalities ?? {
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
provider: model.provider ?? existing?.provider,
|
||||
}
|
||||
if (model.id && model.id !== modelID) {
|
||||
|
|
@ -509,7 +517,14 @@ export namespace Provider {
|
|||
|
||||
const provider = await state().then((state) => state.providers[providerID])
|
||||
if (!provider) return
|
||||
const priority = ["3-5-haiku", "3.5-haiku", "gemini-2.5-flash", "gpt-5-nano"]
|
||||
const priority = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4.5",
|
||||
"3-5-haiku",
|
||||
"3.5-haiku",
|
||||
"gemini-2.5-flash",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
for (const item of priority) {
|
||||
for (const model of Object.keys(provider.info.models)) {
|
||||
if (model.includes(item)) return getModel(providerID, model)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export namespace SessionCompaction {
|
|||
draft.time.compacting = undefined
|
||||
})
|
||||
})
|
||||
const toSummarize = await Session.messages(input.sessionID).then(MessageV2.filterSummarized)
|
||||
const toSummarize = await Session.messages(input.sessionID).then(MessageV2.filterCompacted)
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
const system = [
|
||||
...SystemPrompt.summarize(model.providerID),
|
||||
|
|
@ -109,6 +109,7 @@ export namespace SessionCompaction {
|
|||
const msg = (await Session.updateMessage({
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
parentID: toSummarize.findLast((m) => m.info.role === "user")?.info.id!,
|
||||
sessionID: input.sessionID,
|
||||
system,
|
||||
mode: "build",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Message } from "./message"
|
|||
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
|
||||
import { Identifier } from "../id/id"
|
||||
import { LSP } from "../lsp"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
|
|
@ -243,6 +244,12 @@ export namespace MessageV2 {
|
|||
time: z.object({
|
||||
created: z.number(),
|
||||
}),
|
||||
summary: z
|
||||
.object({
|
||||
diffs: Snapshot.FileDiff.array(),
|
||||
text: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}).meta({
|
||||
ref: "UserMessage",
|
||||
})
|
||||
|
|
@ -281,6 +288,7 @@ export namespace MessageV2 {
|
|||
.optional(),
|
||||
system: z.string().array(),
|
||||
finish: z.string().optional(),
|
||||
parentID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
mode: z.string(),
|
||||
|
|
@ -349,6 +357,7 @@ export namespace MessageV2 {
|
|||
if (v1.role === "assistant") {
|
||||
const info: Assistant = {
|
||||
id: v1.id,
|
||||
parentID: "",
|
||||
sessionID: v1.metadata.sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
|
|
@ -600,7 +609,7 @@ export namespace MessageV2 {
|
|||
return convertToModelMessages(result)
|
||||
}
|
||||
|
||||
export function filterSummarized(msgs: { info: MessageV2.Info; parts: MessageV2.Part[] }[]) {
|
||||
export function filterCompacted(msgs: { info: MessageV2.Info; parts: MessageV2.Part[] }[]) {
|
||||
const i = msgs.findLastIndex((m) => m.info.role === "assistant" && !!m.info.summary)
|
||||
if (i === -1) return msgs.slice()
|
||||
return msgs.slice(i)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { spawn } from "child_process"
|
|||
import { Command } from "../command"
|
||||
import { $, fileURLToPath } from "bun"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { MessageSummary } from "./summary"
|
||||
|
||||
export namespace SessionPrompt {
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
|
|
@ -235,7 +236,7 @@ export namespace SessionPrompt {
|
|||
modelID: model.info.id,
|
||||
})
|
||||
step++
|
||||
await processor.next()
|
||||
await processor.next(msgs.findLast((m) => m.info.role === "user")?.info.id!)
|
||||
await using _ = defer(async () => {
|
||||
await processor.end()
|
||||
})
|
||||
|
|
@ -345,6 +346,11 @@ export namespace SessionPrompt {
|
|||
}
|
||||
state().queued.delete(input.sessionID)
|
||||
SessionCompaction.prune(input)
|
||||
MessageSummary.summarize({
|
||||
sessionID: input.sessionID,
|
||||
messageID: result.info.parentID,
|
||||
providerID: model.providerID,
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -355,7 +361,7 @@ export namespace SessionPrompt {
|
|||
providerID: string
|
||||
signal: AbortSignal
|
||||
}) {
|
||||
let msgs = await Session.messages(input.sessionID).then(MessageV2.filterSummarized)
|
||||
let msgs = await Session.messages(input.sessionID).then(MessageV2.filterCompacted)
|
||||
const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")
|
||||
if (
|
||||
lastAssistant?.info.role === "assistant" &&
|
||||
|
|
@ -900,9 +906,10 @@ export namespace SessionPrompt {
|
|||
let snapshot: string | undefined
|
||||
let blocked = false
|
||||
|
||||
async function createMessage() {
|
||||
async function createMessage(parentID: string) {
|
||||
const msg: MessageV2.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
parentID,
|
||||
role: "assistant",
|
||||
system: input.system,
|
||||
mode: input.agent,
|
||||
|
|
@ -938,11 +945,11 @@ export namespace SessionPrompt {
|
|||
assistantMsg = undefined
|
||||
}
|
||||
},
|
||||
async next() {
|
||||
async next(parentID: string) {
|
||||
if (assistantMsg) {
|
||||
throw new Error("end previous assistant message first")
|
||||
}
|
||||
assistantMsg = await createMessage()
|
||||
assistantMsg = await createMessage(parentID)
|
||||
return assistantMsg
|
||||
},
|
||||
get message() {
|
||||
|
|
@ -1429,6 +1436,7 @@ export namespace SessionPrompt {
|
|||
const msg: MessageV2.Assistant = {
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID: input.sessionID,
|
||||
parentID: userMsg.id,
|
||||
system: [],
|
||||
mode: input.agent,
|
||||
cost: 0,
|
||||
|
|
@ -1701,6 +1709,7 @@ export namespace SessionPrompt {
|
|||
const assistantMsg: MessageV2.Assistant = {
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID: input.sessionID,
|
||||
parentID: userMsg.id,
|
||||
system: [],
|
||||
mode: agentName,
|
||||
cost: 0,
|
||||
|
|
|
|||
5
packages/opencode/src/session/prompt/summarize-turn.txt
Normal file
5
packages/opencode/src/session/prompt/summarize-turn.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Your job is to generate a summary of what happened in this conversation and why.
|
||||
|
||||
Keep the results to 2-3 sentences.
|
||||
|
||||
Output the message summary now:
|
||||
46
packages/opencode/src/session/summary.ts
Normal file
46
packages/opencode/src/session/summary.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Provider } from "@/provider/provider"
|
||||
import { fn } from "@/util/fn"
|
||||
import z from "zod"
|
||||
import { Session } from "."
|
||||
import { generateText } from "ai"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import SUMMARIZE_TURN from "./prompt/summarize-turn.txt"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export namespace MessageSummary {
|
||||
export const summarize = fn(
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
providerID: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_TURN_SUMMARY) return
|
||||
const messages = await Session.messages(input.sessionID).then((msgs) =>
|
||||
msgs.filter(
|
||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||
),
|
||||
)
|
||||
const small = await Provider.getSmallModel(input.providerID)
|
||||
if (!small) return
|
||||
|
||||
const result = await generateText({
|
||||
model: small.language,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: SUMMARIZE_TURN,
|
||||
},
|
||||
...MessageV2.toModelMessage(messages),
|
||||
],
|
||||
})
|
||||
|
||||
const userMsg = messages.find((m) => m.info.id === input.messageID)!
|
||||
userMsg.info.summary = {
|
||||
text: result.text,
|
||||
diffs: [],
|
||||
}
|
||||
await Session.updateMessage(userMsg.info)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1158,9 +1158,9 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
|||
// status.Warn("Agent is working, please wait...")
|
||||
return a, nil
|
||||
}
|
||||
editor := os.Getenv("EDITOR")
|
||||
editor := util.GetEditor()
|
||||
if editor == "" {
|
||||
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
|
||||
return a, toast.NewErrorToast("No editor found. Set EDITOR environment variable (e.g., export EDITOR=vim)")
|
||||
}
|
||||
|
||||
value := a.editor.Value()
|
||||
|
|
@ -1404,10 +1404,9 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
|||
// Format to Markdown
|
||||
markdownContent := formatConversationToMarkdown(messages)
|
||||
|
||||
// Check if EDITOR is set
|
||||
editor := os.Getenv("EDITOR")
|
||||
editor := util.GetEditor()
|
||||
if editor == "" {
|
||||
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
|
||||
return a, toast.NewErrorToast("No editor found. Set EDITOR environment variable (e.g., export EDITOR=vim)")
|
||||
}
|
||||
|
||||
// Create and write to temp file
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package util
|
|||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -45,3 +47,25 @@ func Measure(tag string) func(...any) {
|
|||
slog.Debug(tag, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func GetEditor() string {
|
||||
if editor := os.Getenv("VISUAL"); editor != "" {
|
||||
return editor
|
||||
}
|
||||
if editor := os.Getenv("EDITOR"); editor != "" {
|
||||
return editor
|
||||
}
|
||||
|
||||
commonEditors := []string{"vim", "nvim", "zed", "code", "cursor", "vi", "nano"}
|
||||
if runtime.GOOS == "windows" {
|
||||
commonEditors = []string{"vim", "nvim", "zed", "code.cmd", "cursor.cmd", "notepad.exe", "vi", "nano"}
|
||||
}
|
||||
|
||||
for _, editor := range commonEditors {
|
||||
if _, err := exec.LookPath(editor); err == nil {
|
||||
return editor
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"exports": {
|
||||
".": "./src/components/index.ts",
|
||||
"./*": "./src/components/*.tsx",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./styles": "./src/styles/index.css",
|
||||
"./styles/tailwind": "./src/styles/tailwind/index.css",
|
||||
"./fonts/*": "./src/assets/fonts/*"
|
||||
|
|
@ -23,11 +24,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:"
|
||||
"solid-list": "catalog:",
|
||||
"virtua": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
[data-component="button"] {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -32,12 +31,7 @@
|
|||
border-color: var(--border-weak-base);
|
||||
background-color: var(--button-secondary-base);
|
||||
color: var(--text-strong);
|
||||
|
||||
/* shadow-xs */
|
||||
box-shadow:
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
box-shadow: var(--shadow-xs);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--border-hover);
|
||||
|
|
@ -84,12 +78,11 @@
|
|||
padding: 0 8px 0 6px;
|
||||
gap: 8px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
line-height: var(--line-height-large); /* 171.429% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { Button as Kobalte } from "@kobalte/core/button"
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface ButtonProps {
|
||||
export interface ButtonProps
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "normal" | "large"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
}
|
||||
|
||||
export function Button(props: ComponentProps<"button"> & ButtonProps) {
|
||||
export function Button(props: ButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
return (
|
||||
<Kobalte
|
||||
|
|
|
|||
129
packages/ui/src/components/dialog.css
Normal file
129
packages/ui/src/components/dialog.css
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/* [data-component="dialog-trigger"] { } */
|
||||
|
||||
[data-component="dialog-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background-color: transparent;
|
||||
|
||||
/* animation: overlayHide 250ms ease 100ms forwards; */
|
||||
/**/
|
||||
/* &[data-expanded] { */
|
||||
/* animation: overlayShow 250ms ease; */
|
||||
/* } */
|
||||
}
|
||||
|
||||
[data-component="dialog"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="container"] {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
width: min(calc(100vw - 16px), 624px);
|
||||
height: min(calc(100vh - 16px), 512px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
|
||||
[data-slot="content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
/* padding: 8px; */
|
||||
padding: 8px 8px 0 8px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow:
|
||||
0 15px 45px 0 rgba(19, 16, 16, 0.22),
|
||||
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.13),
|
||||
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.09);
|
||||
|
||||
/* animation: contentHide 300ms ease-in forwards; */
|
||||
/**/
|
||||
/* &[data-expanded] { */
|
||||
/* animation: contentShow 300ms ease-out; */
|
||||
/* } */
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 4px 4px 4px 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="title"] {
|
||||
color: var(--text-strong);
|
||||
|
||||
/* text-16-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-large);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-x-large); /* 150% */
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
/* [data-slot="close-button"] {} */
|
||||
}
|
||||
/* [data-slot="description"] {} */
|
||||
[data-slot="body"] {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes overlayHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes contentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes contentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
91
packages/ui/src/components/dialog.tsx
Normal file
91
packages/ui/src/components/dialog.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
Dialog as Kobalte,
|
||||
DialogRootProps,
|
||||
DialogTitleProps,
|
||||
DialogCloseButtonProps,
|
||||
DialogDescriptionProps,
|
||||
} from "@kobalte/core/dialog"
|
||||
import { ComponentProps, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { IconButton } from "./icon-button"
|
||||
|
||||
export interface DialogProps extends DialogRootProps {
|
||||
trigger?: JSX.Element
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function DialogRoot(props: DialogProps) {
|
||||
let trigger!: HTMLElement
|
||||
const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"])
|
||||
|
||||
const resetTabIndex = () => {
|
||||
trigger.tabIndex = 0
|
||||
}
|
||||
|
||||
const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => {
|
||||
const firstChild = e.currentTarget?.firstElementChild as HTMLElement
|
||||
if (!firstChild) return
|
||||
|
||||
firstChild.focus()
|
||||
trigger.tabIndex = -1
|
||||
|
||||
firstChild.addEventListener("focusout", resetTabIndex)
|
||||
onCleanup(() => {
|
||||
firstChild.removeEventListener("focusout", resetTabIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Kobalte {...others}>
|
||||
<Show when={props.trigger}>
|
||||
<Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}>
|
||||
{props.trigger}
|
||||
</Kobalte.Trigger>
|
||||
</Show>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Overlay data-component="dialog-overlay" />
|
||||
<div data-component="dialog">
|
||||
<div data-slot="container">
|
||||
<Kobalte.Content
|
||||
data-slot="content"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Content>
|
||||
</div>
|
||||
</div>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader(props: ComponentProps<"div">) {
|
||||
return <div data-slot="header" {...props} />
|
||||
}
|
||||
|
||||
function DialogBody(props: ComponentProps<"div">) {
|
||||
return <div data-slot="body" {...props} />
|
||||
}
|
||||
|
||||
function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
|
||||
return <Kobalte.Title data-slot="title" {...props} />
|
||||
}
|
||||
|
||||
function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
|
||||
return <Kobalte.Description data-slot="description" {...props} />
|
||||
}
|
||||
|
||||
function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
|
||||
return <Kobalte.CloseButton data-slot="close-button" as={IconButton} icon="close" {...props} />
|
||||
}
|
||||
|
||||
export const Dialog = Object.assign(DialogRoot, {
|
||||
Header: DialogHeader,
|
||||
Title: DialogTitle,
|
||||
Description: DialogDescription,
|
||||
CloseButton: DialogCloseButton,
|
||||
Body: DialogBody,
|
||||
})
|
||||
117
packages/ui/src/components/icon-button.css
Normal file
117
packages/ui/src/components/icon-button.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
[data-component="icon-button"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
aspect-ratio: 1;
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--icon-strong-disabled);
|
||||
color: var(--icon-invert-base);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
|
||||
[data-slot="icon"] {
|
||||
/* color: var(--icon-weak-base); */
|
||||
color: var(--icon-invert-base);
|
||||
|
||||
/* &:hover:not(:disabled) { */
|
||||
/* color: var(--icon-weak-hover); */
|
||||
/* } */
|
||||
/* &:active:not(:disabled) { */
|
||||
/* color: var(--icon-string-active); */
|
||||
/* } */
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--icon-strong-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--icon-string-active);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--icon-strong-focus);
|
||||
}
|
||||
&:disabled {
|
||||
background-color: var(--icon-strong-disabled);
|
||||
|
||||
[data-slot="icon"] {
|
||||
color: var(--icon-invert-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="secondary"] {
|
||||
background-color: var(--button-secondary-base);
|
||||
color: var(--text-strong);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--surface-active);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--surface-focus);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
background-color: transparent;
|
||||
|
||||
[data-slot="icon"] {
|
||||
color: var(--icon-weak-base);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--icon-weak-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
color: var(--icon-string-active);
|
||||
}
|
||||
}
|
||||
|
||||
/* color: var(--text-strong); */
|
||||
/**/
|
||||
/* &:hover:not(:disabled) { */
|
||||
/* background-color: var(--surface-hover); */
|
||||
/* } */
|
||||
/* &:active:not(:disabled) { */
|
||||
/* background-color: var(--surface-active); */
|
||||
/* } */
|
||||
/* &:focus:not(:disabled) { */
|
||||
/* background-color: var(--surface-focus); */
|
||||
/* } */
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
height: 32px;
|
||||
padding: 0 8px 0 6px;
|
||||
gap: 8px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
}
|
||||
27
packages/ui/src/components/icon-button.tsx
Normal file
27
packages/ui/src/components/icon-button.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Button as Kobalte } from "@kobalte/core/button"
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
import { Icon, IconProps } from "./icon"
|
||||
|
||||
export interface IconButtonProps {
|
||||
icon: IconProps["name"]
|
||||
size?: "normal" | "large"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
}
|
||||
|
||||
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
return (
|
||||
<Kobalte
|
||||
{...rest}
|
||||
data-component="icon-button"
|
||||
data-size={split.size || "normal"}
|
||||
data-variant={split.variant || "secondary"}
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
<Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,4 +3,27 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
/* resize: both; */
|
||||
aspect-ratio: 1/1;
|
||||
color: var(--icon-base);
|
||||
|
||||
&[data-size="small"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
[data-slot="svg"] {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,28 +128,55 @@ const icons = {
|
|||
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
|
||||
} as const
|
||||
|
||||
const newIcons = {
|
||||
"circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
|
||||
"magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
name: keyof typeof icons
|
||||
size?: number
|
||||
name: keyof typeof icons | keyof typeof newIcons
|
||||
size?: "small" | "normal" | "large"
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
||||
const size = local.size ?? 24
|
||||
|
||||
if (local.name in newIcons) {
|
||||
return (
|
||||
<div data-component="icon" data-size={local.size || "normal"}>
|
||||
<svg
|
||||
data-slot="svg"
|
||||
classList={{
|
||||
...(local.classList || {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
innerHTML={newIcons[local.name as keyof typeof newIcons]}
|
||||
aria-hidden="true"
|
||||
{...others}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
data-component="icon"
|
||||
classList={{
|
||||
...(local.classList || {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
innerHTML={icons[local.name]}
|
||||
aria-hidden="true"
|
||||
{...others}
|
||||
/>
|
||||
<div data-component="icon" data-size={local.size || "normal"}>
|
||||
<svg
|
||||
data-slot="svg"
|
||||
classList={{
|
||||
...(local.classList || {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
innerHTML={icons[local.name as keyof typeof icons]}
|
||||
aria-hidden="true"
|
||||
{...others}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
export * from "./button"
|
||||
export * from "./dialog"
|
||||
export * from "./icon"
|
||||
export * from "./icon-button"
|
||||
export * from "./input"
|
||||
export * from "./fonts"
|
||||
export * from "./list"
|
||||
export * from "./select"
|
||||
export * from "./select-dialog"
|
||||
export * from "./tabs"
|
||||
export * from "./tooltip"
|
||||
|
|
|
|||
23
packages/ui/src/components/input.css
Normal file
23
packages/ui/src/components/input.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[data-component="input"] {
|
||||
/* [data-slot="label"] {} */
|
||||
|
||||
[data-slot="input"] {
|
||||
color: var(--text-strong);
|
||||
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/ui/src/components/input.tsx
Normal file
27
packages/ui/src/components/input.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { TextField as Kobalte } from "@kobalte/core/text-field"
|
||||
import { Show, splitProps } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
|
||||
export interface InputProps extends ComponentProps<typeof Kobalte> {
|
||||
label?: string
|
||||
hideLabel?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"])
|
||||
return (
|
||||
<Kobalte {...others} data-component="input">
|
||||
<Show when={local.label}>
|
||||
<Kobalte.Label data-slot="label" classList={{ "sr-only": local.hideLabel }}>
|
||||
{local.label}
|
||||
</Kobalte.Label>
|
||||
</Show>
|
||||
<Kobalte.Input data-slot="input" class={local.class} placeholder={local.placeholder} />
|
||||
<Show when={local.description}>
|
||||
<Kobalte.Description data-slot="description">{local.description}</Kobalte.Description>
|
||||
</Show>
|
||||
<Kobalte.ErrorMessage data-slot="error" />
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
scrollbar-width: none;
|
||||
|
||||
[data-slot="item"] {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
padding: 4px 12px;
|
||||
text-align: left;
|
||||
|
|
@ -23,6 +22,9 @@
|
|||
&[data-active="true"] {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function List<T>(props: ListProps<T>) {
|
|||
// }
|
||||
const handleSelect = (item: T) => {
|
||||
props.onSelect?.(item)
|
||||
list.setActive(props.key(item))
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -64,10 +65,10 @@ export function List<T>(props: ListProps<T>) {
|
|||
data-key={props.key(item)}
|
||||
data-active={props.key(item) === list.active()}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseMove={(e) => {
|
||||
e.currentTarget.focus()
|
||||
onMouseMove={() => {
|
||||
// e.currentTarget.focus()
|
||||
setStore("mouseActive", true)
|
||||
list.setActive(props.key(item))
|
||||
// list.setActive(props.key(item))
|
||||
}}
|
||||
>
|
||||
{props.children(item)}
|
||||
|
|
|
|||
109
packages/ui/src/components/select-dialog.css
Normal file
109
packages/ui/src/components/select-dialog.css
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
[data-component="select-dialog-input"] {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
align-self: stretch;
|
||||
|
||||
border-radius: 8px;
|
||||
background: var(--surface-base);
|
||||
|
||||
[data-slot="input-container"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 0 0;
|
||||
|
||||
/* [data-slot="icon"] {} */
|
||||
|
||||
[data-slot="input"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* [data-slot="clear-button"] {} */
|
||||
}
|
||||
|
||||
[data-component="select-dialog"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="empty-state"] {
|
||||
display: flex;
|
||||
padding: 32px 160px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="message"] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--text-weak);
|
||||
text-align: center;
|
||||
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
[data-slot="filter"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="group"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
|
||||
color: var(--text-weak);
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
[data-slot="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="item"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
align-items: center;
|
||||
|
||||
&[data-active="true"] {
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
packages/ui/src/components/select-dialog.tsx
Normal file
156
packages/ui/src/components/select-dialog.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { createEffect, Show, For, type JSX, splitProps } from "solid-js"
|
||||
import { Dialog, DialogProps, Icon, IconButton, Input } from "@opencode-ai/ui"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
|
||||
interface SelectDialogProps<T>
|
||||
extends FilteredListProps<T>,
|
||||
Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
|
||||
title: string
|
||||
placeholder?: string
|
||||
emptyMessage?: string
|
||||
children: (item: T) => JSX.Element
|
||||
onSelect?: (value: T | undefined) => void
|
||||
}
|
||||
|
||||
export function SelectDialog<T>(props: SelectDialogProps<T>) {
|
||||
const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
|
||||
let closeButton!: HTMLButtonElement
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
mouseActive: false,
|
||||
})
|
||||
|
||||
const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
|
||||
items: others.items,
|
||||
key: others.key,
|
||||
filterKeys: others.filterKeys,
|
||||
current: others.current,
|
||||
groupBy: others.groupBy,
|
||||
sortBy: others.sortBy,
|
||||
sortGroupsBy: others.sortGroupsBy,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
if (store.mouseActive || all.length === 0) return
|
||||
if (active() === others.key(all[0])) {
|
||||
scrollRef?.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
const element = scrollRef?.querySelector(`[data-key="${active()}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
onInput(value)
|
||||
reset()
|
||||
}
|
||||
|
||||
const handleSelect = (item: T | undefined) => {
|
||||
others.onSelect?.(item)
|
||||
closeButton.click()
|
||||
}
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
setStore("mouseActive", false)
|
||||
if (e.key === "Escape") return
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = flat().find((x) => others.key(x) === active())
|
||||
if (selected) handleSelect(selected)
|
||||
} else {
|
||||
onKeyDown(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) clear()
|
||||
props.onOpenChange?.(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog modal {...dialog} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{others.title}</Dialog.Title>
|
||||
<Dialog.CloseButton ref={closeButton} style={{ display: "none" }} />
|
||||
</Dialog.Header>
|
||||
<div data-component="select-dialog-input">
|
||||
<div data-slot="input-container">
|
||||
<Icon data-slot="icon" name="magnifying-glass" />
|
||||
<Input
|
||||
data-slot="input"
|
||||
type="text"
|
||||
value={filter()}
|
||||
onChange={(value) => handleInput(value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={others.placeholder}
|
||||
autofocus
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
<Show when={filter()}>
|
||||
<IconButton
|
||||
data-slot="clear-button"
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onInput("")
|
||||
reset()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Dialog.Body ref={scrollRef} data-component="select-dialog" class="no-scrollbar">
|
||||
<Show
|
||||
when={flat().length > 0}
|
||||
fallback={
|
||||
<div data-slot="empty-state">
|
||||
<div data-slot="message">
|
||||
{props.emptyMessage ?? "No search results"} for <span data-slot="filter">"{filter()}"</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{(group) => (
|
||||
<div data-slot="group">
|
||||
<Show when={group.category}>
|
||||
<div data-slot="header">{group.category}</div>
|
||||
</Show>
|
||||
<div data-slot="list">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
data-slot="item"
|
||||
data-key={others.key(item)}
|
||||
data-active={others.key(item) === active()}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseMove={() => {
|
||||
setStore("mouseActive", true)
|
||||
setActive(others.key(item))
|
||||
}}
|
||||
>
|
||||
{others.children(item)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Dialog.Body>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
[data-component="select"] {
|
||||
[data-slot="trigger"] {
|
||||
padding: 0 4px 0 8px;
|
||||
box-shadow: none;
|
||||
|
||||
[data-slot="value"] {
|
||||
overflow: hidden;
|
||||
|
|
@ -8,8 +9,8 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
[data-slot="icon"] {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
transition: transform 0.1s ease-in-out;
|
||||
|
|
@ -18,15 +19,15 @@
|
|||
}
|
||||
|
||||
[data-component="select-content"] {
|
||||
min-width: 8rem;
|
||||
min-width: 4rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--border-weak-base);
|
||||
background-color: var(--surface-raised-base);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
box-shadow: var(--shadow-md);
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 2px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
z-index: 50;
|
||||
|
||||
&[data-closed] {
|
||||
|
|
@ -42,36 +43,35 @@
|
|||
max-height: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="section"] {
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--text-xs--line-height);
|
||||
font-weight: var(--font-weight-light);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-weak);
|
||||
opacity: 0.6;
|
||||
margin-top: calc(var(--spacing) * 3);
|
||||
margin-left: calc(var(--spacing) * 2);
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
/* [data-slot="section"] { */
|
||||
/* } */
|
||||
|
||||
[data-slot="item"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--text-xs--line-height);
|
||||
color: var(--text-base);
|
||||
cursor: pointer;
|
||||
padding: 0 6px 0 6px;
|
||||
border-radius: 6px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
color: var(--text-strong);
|
||||
|
||||
transition:
|
||||
background-color 0.2s ease-in-out,
|
||||
color 0.2s ease-in-out;
|
||||
|
|
@ -79,24 +79,20 @@
|
|||
user-select: none;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--surface-base);
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
background-color: var(--surface-disabled);
|
||||
background-color: var(--surface-raised-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="item-indicator"] {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-hover);
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
|||
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
|
||||
</Kobalte.ItemLabel>
|
||||
<Kobalte.ItemIndicator data-slot="item-indicator">
|
||||
<Icon name="checkmark" size={16} />
|
||||
<Icon name="checkmark" />
|
||||
</Kobalte.ItemIndicator>
|
||||
</Kobalte.Item>
|
||||
)}
|
||||
|
|
@ -79,7 +79,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
|||
}}
|
||||
</Kobalte.Value>
|
||||
<Kobalte.Icon data-slot="icon">
|
||||
<Icon name="chevron-down" size={16} />
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Kobalte.Icon>
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
background-color: var(--background-stronger);
|
||||
overflow: clip;
|
||||
|
||||
& [data-slot="list"] {
|
||||
[data-slot="list"] {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
& [data-slot="trigger"] {
|
||||
[data-slot="trigger"] {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
padding: 8px 12px;
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-weak);
|
||||
cursor: pointer;
|
||||
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
& [data-slot="content"] {
|
||||
[data-slot="content"] {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function Tooltip(props: TooltipProps) {
|
|||
<KobalteTooltip.Portal>
|
||||
<KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
|
||||
{typeof others.value === "function" ? others.value() : others.value}
|
||||
{/* <KobalteTooltip.Arrow data-slot="arrow" size={18} /> */}
|
||||
{/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
|
||||
</KobalteTooltip.Content>
|
||||
</KobalteTooltip.Portal>
|
||||
</KobalteTooltip>
|
||||
|
|
|
|||
1
packages/ui/src/hooks/index.ts
Normal file
1
packages/ui/src/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./use-filtered-list"
|
||||
89
packages/ui/src/hooks/use-filtered-list.tsx
Normal file
89
packages/ui/src/hooks/use-filtered-list.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import fuzzysort from "fuzzysort"
|
||||
import { entries, flatMap, groupBy, map, pipe } from "remeda"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createList } from "solid-list"
|
||||
|
||||
export interface FilteredListProps<T> {
|
||||
items: T[] | ((filter: string) => Promise<T[]>)
|
||||
key: (item: T) => string
|
||||
filterKeys?: string[]
|
||||
current?: T
|
||||
groupBy?: (x: T) => string
|
||||
sortBy?: (a: T, b: T) => number
|
||||
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
||||
onSelect?: (value: T | undefined) => void
|
||||
}
|
||||
|
||||
export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
|
||||
|
||||
const [grouped] = createResource(
|
||||
() => store.filter,
|
||||
async (filter) => {
|
||||
const needle = filter?.toLowerCase()
|
||||
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
|
||||
const result = pipe(
|
||||
all,
|
||||
(x) => {
|
||||
if (!needle) return x
|
||||
if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
||||
}
|
||||
return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj)
|
||||
},
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, items: props.sortBy ? v.sort(props.sortBy) : v })),
|
||||
(groups) => (props.sortGroupsBy ? groups.sort(props.sortGroupsBy) : groups),
|
||||
)
|
||||
return result
|
||||
},
|
||||
)
|
||||
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped() || [],
|
||||
flatMap((x) => x.items),
|
||||
)
|
||||
})
|
||||
|
||||
const list = createList({
|
||||
items: () => flat().map(props.key),
|
||||
initialActive: props.current ? props.key(props.current) : props.key(flat()[0]),
|
||||
loop: true,
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
const all = flat()
|
||||
if (all.length === 0) return
|
||||
list.setActive(props.key(all[0]))
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
const selected = flat().find((x) => props.key(x) === list.active())
|
||||
if (selected) props.onSelect?.(selected)
|
||||
} else {
|
||||
list.onKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (value: string) => {
|
||||
setStore("filter", value)
|
||||
reset()
|
||||
}
|
||||
|
||||
return {
|
||||
filter: () => store.filter,
|
||||
grouped,
|
||||
flat,
|
||||
reset,
|
||||
clear: () => setStore("filter", ""),
|
||||
onKeyDown,
|
||||
onInput,
|
||||
active: list.active,
|
||||
setActive: list.setActive,
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,13 @@
|
|||
@import "./base.css" layer(base);
|
||||
|
||||
@import "../components/button.css" layer(components);
|
||||
@import "../components/dialog.css" layer(components);
|
||||
@import "../components/icon.css" layer(components);
|
||||
@import "../components/icon-button.css" layer(components);
|
||||
@import "../components/input.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
@import "../components/select.css" layer(components);
|
||||
@import "../components/select-dialog.css" layer(components);
|
||||
@import "../components/tabs.css" layer(components);
|
||||
@import "../components/tooltip.css" layer(components);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
|
||||
/* background-color: var(--color-primary); */
|
||||
/* color: var(--color-background); */
|
||||
}
|
||||
/* ::selection { */
|
||||
/* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */
|
||||
/* background-color: var(--color-primary); */
|
||||
/* color: var(--color-background); */
|
||||
/* } */
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--theme-background-panel);
|
||||
|
|
@ -36,6 +36,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.text-12-regular {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ opencode.server.close()
|
|||
|
||||
## Client only
|
||||
|
||||
If you aready have a running instance of opencode, you can create a client instance to connect to it:
|
||||
If you already have a running instance of opencode, you can create a client instance to connect to it:
|
||||
|
||||
```javascript
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "20.x",
|
||||
"@types/vscode": "^1.102.0",
|
||||
"@types/vscode": "^1.94.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/test-cli": "^0.0.11",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Get the latest Git tag
|
||||
latest_tag=$(git tag --sort=committerdate | grep -E '^vscode-v[0-9]+\.[0-9]+\.[0-9]+$' | tail -1)
|
||||
|
|
@ -7,14 +8,14 @@ if [ -z "$latest_tag" ]; then
|
|||
exit 1
|
||||
fi
|
||||
echo "Latest tag: $latest_tag"
|
||||
version=$(echo $latest_tag | sed 's/^vscode-v//')
|
||||
version=$(echo "$latest_tag" | sed 's/^vscode-v//')
|
||||
echo "Latest version: $version"
|
||||
|
||||
# package-marketplace
|
||||
vsce package --no-git-tag-version --no-update-package-json --no-dependencies --skip-license -o dist/opencode.vsix $version
|
||||
vsce package --no-git-tag-version --no-update-package-json --no-dependencies --skip-license -o dist/opencode.vsix "$version"
|
||||
|
||||
# publish-marketplace
|
||||
vsce publish --packagePath dist/opencode.vsix
|
||||
|
||||
# publish-openvsx
|
||||
npx ovsx publish dist/opencode.vsix -p $OPENVSX_TOKEN
|
||||
npx ovsx publish dist/opencode.vsix -p "$OPENVSX_TOKEN"
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
// This method is called when your extension is deactivated
|
||||
export function deactivate() {}
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import * as vscode from "vscode";
|
||||
|
||||
const TERMINAL_NAME = "opencode"
|
||||
const TERMINAL_NAME = "opencode";
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
let openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => {
|
||||
await openTerminal()
|
||||
})
|
||||
await openTerminal();
|
||||
});
|
||||
|
||||
let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => {
|
||||
// An opencode terminal already exists => focus it
|
||||
const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME)
|
||||
const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME);
|
||||
if (existingTerminal) {
|
||||
existingTerminal.show()
|
||||
return
|
||||
existingTerminal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
await openTerminal()
|
||||
})
|
||||
await openTerminal();
|
||||
});
|
||||
|
||||
let addFilepathDisposable = vscode.commands.registerCommand("opencode.addFilepathToTerminal", async () => {
|
||||
const fileRef = getActiveFile()
|
||||
if (!fileRef) return
|
||||
const fileRef = getActiveFile();
|
||||
if (!fileRef) {return;}
|
||||
|
||||
const terminal = vscode.window.activeTerminal
|
||||
if (!terminal) return
|
||||
const terminal = vscode.window.activeTerminal;
|
||||
if (!terminal) {return;}
|
||||
|
||||
if (terminal.name === TERMINAL_NAME) {
|
||||
// @ts-ignore
|
||||
const port = terminal.creationOptions.env?.["_EXTENSION_OPENCODE_PORT"]
|
||||
port ? await appendPrompt(parseInt(port), fileRef) : terminal.sendText(fileRef)
|
||||
terminal.show()
|
||||
const port = terminal.creationOptions.env?.["_EXTENSION_OPENCODE_PORT"];
|
||||
port ? await appendPrompt(parseInt(port), fileRef) : terminal.sendText(fileRef);
|
||||
terminal.show();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
context.subscriptions.push(openTerminalDisposable, addFilepathDisposable)
|
||||
context.subscriptions.push(openTerminalDisposable, addFilepathDisposable);
|
||||
|
||||
async function openTerminal() {
|
||||
// Create a new terminal in split screen
|
||||
const port = Math.floor(Math.random() * (65535 - 16384 + 1)) + 16384
|
||||
const port = Math.floor(Math.random() * (65535 - 16384 + 1)) + 16384;
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: TERMINAL_NAME,
|
||||
iconPath: {
|
||||
|
|
@ -55,32 +55,32 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
_EXTENSION_OPENCODE_PORT: port.toString(),
|
||||
OPENCODE_CALLER: "vscode",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
terminal.show()
|
||||
terminal.sendText(`opencode --port ${port}`)
|
||||
terminal.show();
|
||||
terminal.sendText(`opencode --port ${port}`);
|
||||
|
||||
const fileRef = getActiveFile()
|
||||
if (!fileRef) return
|
||||
const fileRef = getActiveFile();
|
||||
if (!fileRef) {return;}
|
||||
|
||||
// Wait for the terminal to be ready
|
||||
let tries = 10
|
||||
let connected = false
|
||||
let tries = 10;
|
||||
let connected = false;
|
||||
do {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/app`)
|
||||
connected = true
|
||||
break
|
||||
await fetch(`http://localhost:${port}/app`);
|
||||
connected = true;
|
||||
break;
|
||||
} catch (e) {}
|
||||
|
||||
tries--
|
||||
} while (tries > 0)
|
||||
tries--;
|
||||
} while (tries > 0);
|
||||
|
||||
// If connected, append the prompt to the terminal
|
||||
if (connected) {
|
||||
await appendPrompt(port, `In ${fileRef}`)
|
||||
terminal.show()
|
||||
await appendPrompt(port, `In ${fileRef}`);
|
||||
terminal.show();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,37 +91,37 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveFile() {
|
||||
const activeEditor = vscode.window.activeTextEditor
|
||||
if (!activeEditor) return
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (!activeEditor) {return;}
|
||||
|
||||
const document = activeEditor.document
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
|
||||
if (!workspaceFolder) return
|
||||
const document = activeEditor.document;
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||
if (!workspaceFolder) {return;}
|
||||
|
||||
// Get the relative path from workspace root
|
||||
const relativePath = vscode.workspace.asRelativePath(document.uri)
|
||||
let filepathWithAt = `@${relativePath}`
|
||||
const relativePath = vscode.workspace.asRelativePath(document.uri);
|
||||
let filepathWithAt = `@${relativePath}`;
|
||||
|
||||
// Check if there's a selection and add line numbers
|
||||
const selection = activeEditor.selection
|
||||
const selection = activeEditor.selection;
|
||||
if (!selection.isEmpty) {
|
||||
// Convert to 1-based line numbers
|
||||
const startLine = selection.start.line + 1
|
||||
const endLine = selection.end.line + 1
|
||||
const startLine = selection.start.line + 1;
|
||||
const endLine = selection.end.line + 1;
|
||||
|
||||
if (startLine === endLine) {
|
||||
// Single line selection
|
||||
filepathWithAt += `#L${startLine}`
|
||||
filepathWithAt += `#L${startLine}`;
|
||||
} else {
|
||||
// Multi-line selection
|
||||
filepathWithAt += `#L${startLine}-${endLine}`
|
||||
filepathWithAt += `#L${startLine}-${endLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
return filepathWithAt
|
||||
return filepathWithAt;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"typeRoots": ["./node_modules/@types"],
|
||||
"strict": true /* enable all strict type-checking options */
|
||||
"strict": true /* enable all strict type-checking options */,
|
||||
"skipLibCheck": true
|
||||
/* Additional Checks */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue