mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 23:09:50 -04:00
Compare commits
16 Commits
revert-id-order
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| fec4f20736 | |||
| 49d07ffe5f | |||
| 28f6968dda | |||
| 979ac810af | |||
| 71caa7d406 | |||
| 82b96eb495 | |||
| 7909817fba | |||
| 23b7632743 | |||
| de28ca9f0e | |||
| 9d9e57941c | |||
| 0e28d651a5 | |||
| a5b6ead43e | |||
| 7a73aad0a2 | |||
| 304c85fb2b | |||
| 85988b957b | |||
| 146aeef830 |
@@ -0,0 +1,48 @@
|
||||
# he Glossary
|
||||
|
||||
## Sources
|
||||
|
||||
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
|
||||
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
|
||||
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
|
||||
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
|
||||
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
|
||||
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
|
||||
|
||||
## Do Not Translate (Locale Additions)
|
||||
|
||||
- `OpenCode` (preserve casing in prose and UI copy)
|
||||
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
|
||||
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
|
||||
- Keep `commit` and `diff` when they name the exact Git artifact or operation
|
||||
|
||||
## Preferred Terms
|
||||
|
||||
| English / Context | Preferred | Notes |
|
||||
| ----------------- | ------------- | ------------------------------------------------------------------------- |
|
||||
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
|
||||
| workspace | `סביבת עבודה` | |
|
||||
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
|
||||
| command | `פקודה` | |
|
||||
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
|
||||
| model | `מודל` | |
|
||||
| API key | `מפתח API` | Keep the acronym in Latin letters |
|
||||
| plugin | `תוסף` | |
|
||||
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
|
||||
| branch | `ענף` | |
|
||||
| context | `הקשר` | Use `חלון הקשר` for context window |
|
||||
| tokens | `אסימונים` | |
|
||||
|
||||
## Guidance
|
||||
|
||||
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
|
||||
- Use short action verbs for controls and translate complete phrases in context.
|
||||
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
|
||||
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
|
||||
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
|
||||
- Avoid translating `commit` as `התחייבות`.
|
||||
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
|
||||
@@ -70,19 +70,13 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
@@ -736,8 +730,6 @@
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
@@ -960,8 +952,6 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
@@ -974,8 +964,6 @@
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
@@ -1038,7 +1026,6 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"resolve.exports": "catalog:",
|
||||
"xdg-basedir": "5.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1104,7 +1091,6 @@
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"vitest": "3.2.7",
|
||||
"wrangler": "4.28.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
},
|
||||
},
|
||||
"packages/www": {
|
||||
@@ -2745,7 +2731,7 @@
|
||||
|
||||
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
||||
|
||||
@@ -2759,11 +2745,11 @@
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@shikijs/twoslash": ["@shikijs/twoslash@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/types": "4.4.2", "twoslash": "^0.3.9" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-JjPKNbYCZn5+DVOxDZJ0ZruFPBM7m2JQBp4WbLh4tU2y3ed93wBCaQhKwMyfTFIsDzHks22szAkUUd2+jIPo/w=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="],
|
||||
"@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
@@ -2881,12 +2867,6 @@
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@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/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
|
||||
|
||||
"@solid-primitives/bounds": ["@solid-primitives/bounds@0.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="],
|
||||
|
||||
"@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.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="],
|
||||
@@ -2911,20 +2891,14 @@
|
||||
|
||||
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
|
||||
|
||||
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
|
||||
|
||||
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA=="],
|
||||
|
||||
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12", "solid-start": "*" }, "optionalPeers": ["@tauri-apps/plugin-store", "solid-start"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
||||
|
||||
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
|
||||
|
||||
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ=="],
|
||||
|
||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="],
|
||||
|
||||
"@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="],
|
||||
|
||||
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
|
||||
|
||||
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
|
||||
@@ -4919,9 +4893,9 @@
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
|
||||
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
@@ -5999,8 +5973,6 @@
|
||||
|
||||
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
@@ -6493,8 +6465,6 @@
|
||||
|
||||
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@opencode-ai/workerd-spike/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="],
|
||||
|
||||
"@opencode-ai/workerd-spike/wrangler": ["wrangler@4.28.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.6.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250803.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.19", "workerd": "1.20250803.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250803.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-y0yHIuScpok9oSErLqDbxkBChC2+/jZpvqMg2NxOto1JCyUtDUuKljOfcVMaI48d9GuhOCSoWSumYxLAHNxaLA=="],
|
||||
@@ -6519,8 +6489,6 @@
|
||||
|
||||
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@pierre/diffs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
@@ -6581,17 +6549,9 @@
|
||||
|
||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||
|
||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
"@shikijs/twoslash/@shikijs/core": ["@shikijs/core@4.4.2", "", { "dependencies": { "@shikijs/primitive": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA=="],
|
||||
|
||||
@@ -6987,10 +6947,6 @@
|
||||
|
||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||
|
||||
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
@@ -7657,10 +7613,6 @@
|
||||
|
||||
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
"@opencode-ai/workerd-spike/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"@opencode-ai/workerd-spike/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
@@ -7739,10 +7691,6 @@
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
"@radix-ui/react-arrow/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"@radix-ui/react-collapsible/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
@@ -7783,8 +7731,6 @@
|
||||
|
||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/twoslash/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA=="],
|
||||
|
||||
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
@@ -8037,10 +7983,6 @@
|
||||
|
||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
@@ -63,19 +63,13 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
export type Locale = DesktopNativeLocale
|
||||
export type Direction = "ltr" | "rtl"
|
||||
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "he", "ur", "pa", "fa", "dv"])
|
||||
|
||||
function localeDirection(locale: Locale): Direction {
|
||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||
@@ -73,6 +73,7 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
|
||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||
he: () => merge(import("@/i18n/he"), import("@opencode-ai/ui/i18n/he")),
|
||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("desktop native translations", () => {
|
||||
"Українська",
|
||||
"Bosanski",
|
||||
"العربية",
|
||||
"עברית",
|
||||
"Norsk",
|
||||
"Português (Brasil)",
|
||||
"ไทย",
|
||||
@@ -133,6 +134,11 @@ describe("desktop native locale detection", () => {
|
||||
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
|
||||
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
|
||||
})
|
||||
|
||||
test("recognizes Hebrew language tags", () => {
|
||||
expect(detectDesktopNativeLocale(["he"])).toBe("he")
|
||||
expect(detectDesktopNativeLocale(["he-IL"])).toBe("he")
|
||||
})
|
||||
})
|
||||
|
||||
describe("desktop native ICU data", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DESKTOP_NATIVE_LOCALES = [
|
||||
"uk",
|
||||
"bs",
|
||||
"ar",
|
||||
"he",
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
@@ -80,6 +81,7 @@ export const DESKTOP_NATIVE_LABELS: Record<DesktopNativeLocale, string> = {
|
||||
uk: "Українська",
|
||||
bs: "Bosanski",
|
||||
ar: "العربية",
|
||||
he: "עברית",
|
||||
no: "Norsk",
|
||||
br: "Português (Brasil)",
|
||||
th: "ไทย",
|
||||
@@ -145,6 +147,7 @@ export const DESKTOP_NATIVE_LOCALE_TAGS: Record<DesktopNativeLocale, string> = {
|
||||
uk: "uk",
|
||||
bs: "bs",
|
||||
ar: "ar",
|
||||
he: "he-IL",
|
||||
no: "nb-NO",
|
||||
br: "pt-BR",
|
||||
th: "th",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,10 +158,6 @@ export interface Interface {
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) => Effect.Effect<void>
|
||||
readonly replayAll: (
|
||||
events: SerializedEvent[],
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) => Effect.Effect<string | undefined>
|
||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
||||
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
|
||||
}
|
||||
@@ -648,28 +644,6 @@ export function configured(options?: Options) {
|
||||
})
|
||||
}
|
||||
|
||||
function replayAll(
|
||||
events: SerializedEvent[],
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const source = events[0]?.aggregateID
|
||||
if (!source) return undefined
|
||||
if (events.some((event) => event.aggregateID !== source)) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: events[0]?.type ?? "unknown",
|
||||
message: "Replay events must belong to the same aggregate",
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const event of events) {
|
||||
yield* replay(event, options)
|
||||
}
|
||||
return source
|
||||
})
|
||||
}
|
||||
|
||||
function remove(aggregateID: string) {
|
||||
return db
|
||||
.transaction(() =>
|
||||
@@ -866,7 +840,6 @@ export function configured(options?: Options) {
|
||||
listen,
|
||||
project,
|
||||
replay,
|
||||
replayAll,
|
||||
remove,
|
||||
claim,
|
||||
})
|
||||
|
||||
@@ -239,58 +239,5 @@ export class SQLiteEffectDatabase<
|
||||
) => Effect.Effect<A, E | SqlError, R> = (tx, config) => this.session.transaction(tx, config)
|
||||
}
|
||||
|
||||
export type SQLiteEffectWithReplicas<Q> = Q & { $primary: Q; $replicas: Q[] }
|
||||
|
||||
export const withReplicas = <
|
||||
TEffectHKT extends QueryEffectHKTBase,
|
||||
TRunResult,
|
||||
TRelations extends AnyRelations,
|
||||
Q extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations>,
|
||||
>(
|
||||
primary: Q,
|
||||
replicas: [Q, ...Q[]],
|
||||
getReplica: (replicas: Q[]) => Q = () => replicas[Math.floor(Math.random() * replicas.length)]!,
|
||||
): SQLiteEffectWithReplicas<Q> => {
|
||||
const select: Q["select"] = (...args: []) => getReplica(replicas).select(...args)
|
||||
const selectDistinct: Q["selectDistinct"] = (...args: []) => getReplica(replicas).selectDistinct(...args)
|
||||
const $count: Q["$count"] = (...args: [any]) => getReplica(replicas).$count(...args)
|
||||
const _with: Q["with"] = (...args: []) => getReplica(replicas).with(...args)
|
||||
const $with = ((...args: [string] | [string, ColumnsSelection]) =>
|
||||
args.length === 1
|
||||
? getReplica(replicas).$with(args[0])
|
||||
: getReplica(replicas).$with(args[0], args[1])) as Q["$with"]
|
||||
|
||||
const update: Q["update"] = (...args: [any]) => primary.update(...args)
|
||||
const insert: Q["insert"] = (...args: [any]) => primary.insert(...args)
|
||||
const $delete: Q["delete"] = (...args: [any]) => primary.delete(...args)
|
||||
const run: Q["run"] = (...args: [any]) => primary.run(...args)
|
||||
const all: Q["all"] = (...args: [any]) => primary.all(...args)
|
||||
const get: Q["get"] = (...args: [any]) => primary.get(...args)
|
||||
const values: Q["values"] = (...args: [any]) => primary.values(...args)
|
||||
const transaction: Q["transaction"] = (...args: [any]) => primary.transaction(...args)
|
||||
|
||||
return {
|
||||
...primary,
|
||||
update,
|
||||
insert,
|
||||
delete: $delete,
|
||||
run,
|
||||
all,
|
||||
get,
|
||||
values,
|
||||
transaction,
|
||||
$primary: primary,
|
||||
$replicas: replicas,
|
||||
select,
|
||||
selectDistinct,
|
||||
$count,
|
||||
$with,
|
||||
with: _with,
|
||||
get query() {
|
||||
return getReplica(replicas).query
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type AnySQLiteEffectDatabase = SQLiteEffectDatabase<any, any, any>
|
||||
export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
/* oxlint-disable */
|
||||
import type { TablesRelationalConfig } from "drizzle-orm/_relations"
|
||||
import type { MigrationMeta } from "drizzle-orm/migrator"
|
||||
import type { AnyRelations } from "drizzle-orm/relations"
|
||||
import { type SQL, sql } from "drizzle-orm/sql/sql"
|
||||
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"
|
||||
import type { SQLiteSession } from "drizzle-orm/sqlite-core/session"
|
||||
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils.js"
|
||||
|
||||
/** @internal */
|
||||
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
|
||||
|
||||
type AsyncSQLiteDatabaseWithSession = BaseSQLiteDatabase<"async", unknown, Record<string, unknown>> & {
|
||||
session: {
|
||||
all<T>(query: SQL): Promise<T[]>
|
||||
}
|
||||
transaction<T>(transaction: (tx: { run(query: SQL): Promise<unknown> }) => Promise<T>): Promise<T>
|
||||
}
|
||||
|
||||
type SQLiteMigrationBackfillEntry = {
|
||||
name: string
|
||||
selector:
|
||||
@@ -115,139 +103,3 @@ export function buildSQLiteMigrationBackfillStatements(
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the current version of the migrations table schema and upgrades it if needed.
|
||||
*
|
||||
* Version 0: Original schema (id, hash, created_at)
|
||||
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
|
||||
*/
|
||||
export function upgradeSyncIfNeeded(
|
||||
migrationsTable: string,
|
||||
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
|
||||
localMigrations: MigrationMeta[],
|
||||
): UpgradeResult {
|
||||
const tableExists = session.all(sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`)
|
||||
|
||||
if (tableExists.length === 0) {
|
||||
return { newDb: true }
|
||||
}
|
||||
|
||||
// Table exists, check table shape
|
||||
const rows = session.all<{ column_name: string }>(
|
||||
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
|
||||
)
|
||||
|
||||
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
|
||||
|
||||
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
|
||||
const upgradeFn = upgradeSyncFunctions[v]
|
||||
if (!upgradeFn) {
|
||||
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
|
||||
}
|
||||
upgradeFn(migrationsTable, session, localMigrations)
|
||||
}
|
||||
|
||||
return { newDb: false }
|
||||
}
|
||||
|
||||
const upgradeSyncFunctions: Record<
|
||||
number,
|
||||
(
|
||||
migrationsTable: string,
|
||||
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
|
||||
localMigrations: MigrationMeta[],
|
||||
) => void
|
||||
> = {
|
||||
/**
|
||||
* Upgrade from version 0 to version 1:
|
||||
* 1. Read all existing DB migrations
|
||||
* 2. Sort localMigrations ASC by millis and if the same - sort by name
|
||||
* 3. Match each DB row to a local migration
|
||||
* If multiple migrations share the same second, use hash matching as a tiebreaker
|
||||
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
|
||||
* 5. Create extra column and backfill names for matched migrations
|
||||
*/
|
||||
0: (migrationsTable, session, localMigrations) => {
|
||||
const table = sql`${sql.identifier(migrationsTable)}`
|
||||
const dbRows = session.all<SQLiteMigrationTableRow>(sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`)
|
||||
const statements = buildSQLiteMigrationBackfillStatements(
|
||||
migrationsTable,
|
||||
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
|
||||
)
|
||||
|
||||
session.transaction((tx) => {
|
||||
for (const statement of statements) {
|
||||
tx.run(statement)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the current version of the migrations table schema and upgrades it if needed.
|
||||
*
|
||||
* Version 0: Original schema (id, hash, created_at)
|
||||
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
|
||||
*/
|
||||
export async function upgradeAsyncIfNeeded(
|
||||
migrationsTable: string,
|
||||
db: AsyncSQLiteDatabaseWithSession,
|
||||
localMigrations: MigrationMeta[],
|
||||
): Promise<UpgradeResult> {
|
||||
// Check if the table exists at all
|
||||
const tableExists = await db.session.all(
|
||||
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
|
||||
)
|
||||
|
||||
if (tableExists.length === 0) {
|
||||
return { newDb: true }
|
||||
}
|
||||
|
||||
const rows = await db.session.all<{ column_name: string }>(
|
||||
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
|
||||
)
|
||||
|
||||
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
|
||||
|
||||
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
|
||||
const upgradeFn = upgradeAsyncFunctions[v]
|
||||
if (!upgradeFn) {
|
||||
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
|
||||
}
|
||||
await upgradeFn(migrationsTable, db, localMigrations)
|
||||
}
|
||||
|
||||
return { newDb: false }
|
||||
}
|
||||
|
||||
const upgradeAsyncFunctions: Record<
|
||||
number,
|
||||
(migrationsTable: string, db: AsyncSQLiteDatabaseWithSession, localMigrations: MigrationMeta[]) => Promise<void>
|
||||
> = {
|
||||
/**
|
||||
* Upgrade from version 0 to version 1:
|
||||
* 1. Read all existing DB migrations
|
||||
* 2. Sort localMigrations ASC by millis and if the same - sort by name
|
||||
* 3. Match each DB row to a local migration
|
||||
* If multiple migrations share the same second, use hash matching as a tiebreaker
|
||||
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
|
||||
* 5. Create extra column and backfill names for matched migrations
|
||||
*/
|
||||
0: async (migrationsTable, db, localMigrations) => {
|
||||
const table = sql`${sql.identifier(migrationsTable)}`
|
||||
const dbRows = await db.session.all<SQLiteMigrationTableRow>(
|
||||
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
|
||||
)
|
||||
const statements = buildSQLiteMigrationBackfillStatements(
|
||||
migrationsTable,
|
||||
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
|
||||
)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const statement of statements) {
|
||||
await tx.run(statement)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -223,8 +223,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
|
||||
export function validateAnswer(form: ReadonlyArray<Form.Field>, answer: Answer) {
|
||||
|
||||
@@ -195,18 +195,6 @@ export function hash(value: Schema.Json) {
|
||||
return Hash.make(createHash("sha256").update(canonical(value)).digest("hex"))
|
||||
}
|
||||
|
||||
export function applyDelta(
|
||||
values: Readonly<Record<string, Schema.Json>>,
|
||||
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
|
||||
): Readonly<Record<string, Schema.Json>> {
|
||||
const result: Record<string, Schema.Json> = { ...values }
|
||||
for (const [key, value] of Object.entries(delta)) {
|
||||
if (Option.isNone(value)) delete result[key]
|
||||
else result[key] = value.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyHashDelta(values: Values, delta: Delta): Values {
|
||||
const result: Record<string, Hash> = { ...values }
|
||||
for (const [key, value] of Object.entries(delta)) {
|
||||
|
||||
@@ -89,7 +89,6 @@ export type BackgroundAllInput = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly start: (input: StartInput) => Effect.Effect<Info>
|
||||
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
||||
@@ -187,12 +186,6 @@ export const make = Effect.gen(function* () {
|
||||
)
|
||||
})
|
||||
|
||||
const list: Interface["list"] = Effect.fn("Job.list")(function* () {
|
||||
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => a.started_at - b.started_at)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job) return undefined
|
||||
@@ -360,7 +353,7 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel })
|
||||
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
|
||||
})
|
||||
|
||||
const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -19,8 +19,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
import { Location } from "../location.js"
|
||||
import { waitForAbort } from "@opencode-ai/util/process"
|
||||
import { State } from "../state.js"
|
||||
import { MCPClient } from "./client.js"
|
||||
import { MCPOAuth } from "./oauth.js"
|
||||
import type { MCPClient } from "./client.js"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export type ServerName = typeof ServerName.Type
|
||||
@@ -245,7 +244,11 @@ export const layer = (options?: Options) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: name },
|
||||
authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
|
||||
}),
|
||||
})
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
@@ -264,6 +267,7 @@ export const layer = (options?: Options) =>
|
||||
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
|
||||
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
|
||||
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const base = {
|
||||
@@ -505,6 +509,7 @@ export const layer = (options?: Options) =>
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ToolOutput from "./tool-output.js"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -24,15 +24,23 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
|
||||
|
||||
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)))
|
||||
const cutoff = Date.now() - Duration.toMillis(RETENTION)
|
||||
const entries = yield* fs.readDirectory(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
yield* Effect.forEach(
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, entry)
|
||||
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const mtime = info && Option.getOrUndefined(info.mtime)
|
||||
if (!mtime || mtime.getTime() >= cutoff) return
|
||||
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
{ concurrency: 8, discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
||||
@@ -10,15 +10,6 @@ export function base64Decode(value: string) {
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
export async function hash(content: string, algorithm = "SHA-256"): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest(algorithm, data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
return hashHex
|
||||
}
|
||||
|
||||
export function checksum(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
let hash = 0x811c9dc5
|
||||
|
||||
@@ -879,88 +879,6 @@ describe("Bus", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replayAll validates contiguous aggregate events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
const source = yield* bus.replayAll([
|
||||
{
|
||||
id: Event.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: Bus.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: durableData(aggregateID, "one"),
|
||||
},
|
||||
{
|
||||
id: Event.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: Bus.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: durableData(aggregateID, "two"),
|
||||
},
|
||||
])
|
||||
|
||||
expect(source).toBe(aggregateID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replayAll accepts later chunks after the first batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
|
||||
const one = yield* bus.replayAll([
|
||||
{
|
||||
id: Event.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: Bus.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: durableData(aggregateID, "one"),
|
||||
},
|
||||
{
|
||||
id: Event.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: Bus.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: durableData(aggregateID, "two"),
|
||||
},
|
||||
])
|
||||
const two = yield* bus.replayAll([
|
||||
{
|
||||
id: Event.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: Bus.versionedType(DurableMessage.type, 1),
|
||||
seq: 2,
|
||||
aggregateID,
|
||||
data: durableData(aggregateID, "three"),
|
||||
},
|
||||
{
|
||||
id: Event.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: Bus.versionedType(DurableMessage.type, 1),
|
||||
seq: 3,
|
||||
aggregateID,
|
||||
data: durableData(aggregateID, "four"),
|
||||
},
|
||||
])
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, aggregateID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(one).toBe(aggregateID)
|
||||
expect(two).toBe(aggregateID)
|
||||
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("claim fences replay owners", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
@@ -55,7 +55,6 @@ const integrations = Layer.mock(Integration.Service, {
|
||||
})
|
||||
const npm = Layer.mock(Npm.Service, {
|
||||
add: () => Effect.die("unused"),
|
||||
install: () => Effect.die("unused"),
|
||||
which: () => Effect.die("unused"),
|
||||
})
|
||||
const aisdk = Layer.mock(AISDK.Service, {
|
||||
|
||||
@@ -125,9 +125,6 @@ describe("Instructions", () => {
|
||||
expect(
|
||||
Instructions.renderUpdate(instructions, { "api/value": "previous" }, { "api/value": Option.some(null) }),
|
||||
).toBe("null")
|
||||
expect(Instructions.applyDelta({ "api/value": "previous" }, { "api/value": Option.some(null) })).toEqual({
|
||||
"api/value": null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ export const readUpdate = (instructions: Instructions.List, previous: State) =>
|
||||
hash === "removed" ? Option.none() : Option.some(admission.blobs[hash]),
|
||||
]),
|
||||
) as Readonly<Record<string, Option.Option<Schema.Json>>>
|
||||
const values = Instructions.applyDelta(previous.values, delta)
|
||||
const values: Record<string, Schema.Json> = { ...previous.values }
|
||||
for (const [key, value] of Object.entries(delta)) {
|
||||
if (Option.isNone(value)) delete values[key]
|
||||
else values[key] = value.value
|
||||
}
|
||||
return {
|
||||
values,
|
||||
text: Instructions.renderUpdate(instructions, previous.values, delta),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../../..")
|
||||
|
||||
test("loads the MCP SDK only when connecting or authorizing", async () => {
|
||||
const temporary = await mkdtemp(path.join(import.meta.dir, ".mcp-import-boundary-"))
|
||||
const metafile = path.join(temporary, "meta.json")
|
||||
try {
|
||||
const result = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"build",
|
||||
"packages/core/src/mcp/index.ts",
|
||||
"--target=node",
|
||||
"--format=esm",
|
||||
"--packages=bundle",
|
||||
"--splitting",
|
||||
`--metafile=${metafile}`,
|
||||
`--outdir=${path.join(temporary, "out")}`,
|
||||
],
|
||||
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
result.exited,
|
||||
new Response(result.stdout).text(),
|
||||
new Response(result.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
|
||||
const metadata = await Bun.file(metafile).json()
|
||||
const imports = metadata.inputs["packages/core/src/mcp/index.ts"].imports
|
||||
const lazy = imports.filter(
|
||||
(item: { original?: string }) => item.original === "./client.js" || item.original === "./oauth.js",
|
||||
)
|
||||
expect(new Set(lazy.map((item: { original: string }) => item.original))).toEqual(
|
||||
new Set(["./client.js", "./oauth.js"]),
|
||||
)
|
||||
expect(lazy.every((item: { kind: string }) => item.kind === "dynamic-import")).toBe(true)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -36,6 +36,48 @@ describe("Npm.sanitize", () => {
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "@fixture/provider@1.0.0"
|
||||
const directory = path.join(
|
||||
tmp.path,
|
||||
"cache",
|
||||
"packages",
|
||||
Npm.sanitize(spec),
|
||||
"node_modules",
|
||||
"@fixture",
|
||||
"provider",
|
||||
)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, { name: "@fixture/provider", exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
|
||||
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("falls back to the original spec when parsing fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "fixture provider"
|
||||
const directory = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec), "node_modules", spec)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, { name: spec, exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
|
||||
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("reifies when package cache directory exists without the package installed", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
|
||||
|
||||
@@ -29,7 +29,6 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
|
||||
@@ -629,7 +629,7 @@ describe("Session.create", () => {
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(yield* store.get(created.id)).toBeUndefined()
|
||||
expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
||||
yield* Effect.forEach(serialized.slice(0, 2), (event) => bus.replay(event), { discard: true })
|
||||
expect(yield* SessionInbox.find(db, admitted.id)).toMatchObject({
|
||||
id: admitted.id,
|
||||
sessionID: created.id,
|
||||
@@ -639,7 +639,7 @@ describe("Session.create", () => {
|
||||
})
|
||||
expect(yield* store.context(created.id)).toEqual([])
|
||||
|
||||
expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
|
||||
yield* Effect.forEach(serialized.slice(2), (event) => bus.replay(event), { discard: true })
|
||||
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
|
||||
expect(yield* store.context(created.id)).toMatchObject([
|
||||
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
||||
|
||||
@@ -767,7 +767,7 @@ describe("Session.prompt", () => {
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* bus.replayAll(
|
||||
yield* Effect.forEach(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
@@ -776,6 +776,8 @@ describe("Session.prompt", () => {
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
(event) => bus.replay(event),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({
|
||||
|
||||
@@ -635,7 +635,7 @@ const replaySessionProjection = (id: Session.ID) =>
|
||||
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* bus.replayAll(
|
||||
yield* Effect.forEach(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
@@ -644,6 +644,8 @@ const replaySessionProjection = (id: Session.ID) =>
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
(event) => bus.replay(event),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1343,7 +1345,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.all()
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
|
||||
yield* bus.replayAll(
|
||||
yield* Effect.forEach(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
@@ -1352,6 +1354,8 @@ describe("SessionRunnerLLM", () => {
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
(event) => bus.replay(event),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(
|
||||
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, forked.id)).get(),
|
||||
|
||||
@@ -143,15 +143,16 @@ describe("ToolOutput", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes expired managed files", () =>
|
||||
it.live("uses file modification time when IDs wrap", () =>
|
||||
withStore((output, fs, root) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(root, ToolOutput.DIRECTORY)
|
||||
const old = path.join(directory, Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000))
|
||||
const recent = path.join(directory, Identifier.ascending("tool"))
|
||||
const old = path.join(directory, Identifier.create("tool", "ascending", 2 ** 36 - 1))
|
||||
const recent = path.join(directory, Identifier.create("tool", "ascending", 2 ** 36 + 1))
|
||||
yield* fs.ensureDir(directory)
|
||||
yield* fs.writeFileString(old, "old")
|
||||
yield* fs.writeFileString(recent, "recent")
|
||||
yield* fs.utimes(old, new Date(), new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000))
|
||||
yield* output.cleanup()
|
||||
expect(yield* fs.exists(old)).toBe(false)
|
||||
expect(yield* fs.exists(recent)).toBe(true)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "בדוק אם יש עדכונים...",
|
||||
"desktop.menu.reloadWebview": "טען מחדש את תצוגת האינטרנט",
|
||||
"desktop.menu.restart": "הפעל מחדש",
|
||||
"desktop.dialog.chooseFolder": "בחירת תיקייה",
|
||||
"desktop.dialog.chooseFile": "בחירת קובץ",
|
||||
"desktop.dialog.saveFile": "שמירת קובץ",
|
||||
"desktop.updater.checkFailed.title": "בדיקת העדכונים נכשלה",
|
||||
"desktop.updater.checkFailed.message": "לא ניתן לבדוק אם קיימים עדכונים",
|
||||
"desktop.updater.none.title": "אין עדכון זמין",
|
||||
"desktop.updater.none.message": "כבר מותקנת הגרסה העדכנית ביותר של OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "העדכון נכשל",
|
||||
"desktop.updater.downloadFailed.message": "הורדת העדכון נכשלה",
|
||||
"desktop.updater.downloaded.title": "העדכון הורד",
|
||||
"desktop.updater.downloaded.prompt": "גרסה {{version}} של OpenCode הורדה. להתקין אותה ולהפעיל מחדש את היישום?",
|
||||
"desktop.updater.installFailed.title": "העדכון נכשל",
|
||||
"desktop.updater.installFailed.message": "התקנת העדכון נכשלה",
|
||||
"desktop.error.dev.rootNotFound":
|
||||
"רכיב השורש לא נמצא. האם שכחת להוסיף אותו ל-index.html, או שיש טעות בשם מאפיין ה-id?",
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { dict as desktopPl } from "./pl"
|
||||
import { dict as desktopRu } from "./ru"
|
||||
import { dict as desktopUk } from "./uk"
|
||||
import { dict as desktopAr } from "./ar"
|
||||
import { dict as desktopHe } from "./he"
|
||||
import { dict as desktopNo } from "./no"
|
||||
import { dict as desktopBr } from "./br"
|
||||
import { dict as desktopBs } from "./bs"
|
||||
@@ -126,6 +127,7 @@ function build(locale: Locale): Dictionary {
|
||||
if (locale === "ru") return { ...base, ...i18n.flatten(desktopRu) }
|
||||
if (locale === "uk") return { ...base, ...i18n.flatten(desktopUk) }
|
||||
if (locale === "ar") return { ...base, ...i18n.flatten(desktopAr) }
|
||||
if (locale === "he") return { ...base, ...i18n.flatten(desktopHe) }
|
||||
if (locale === "no") return { ...base, ...i18n.flatten(desktopNo) }
|
||||
if (locale === "br") return { ...base, ...i18n.flatten(desktopBr) }
|
||||
if (locale === "bs") return { ...base, ...i18n.flatten(desktopBs) }
|
||||
|
||||
@@ -4,9 +4,4 @@ import { Event } from "./event.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
|
||||
export const SessionDurable = {
|
||||
definitions: Event.durableMap(SessionEvent.DurableDefinitions),
|
||||
schema: SessionEvent.Durable,
|
||||
} as const
|
||||
|
||||
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, Worktree.Event.Resolved])
|
||||
|
||||
@@ -17,14 +17,6 @@ export const ResourcesChanged = Event.ephemeral({
|
||||
},
|
||||
})
|
||||
|
||||
export const BrowserOpenFailed = Event.ephemeral({
|
||||
type: "mcp.browser.open.failed",
|
||||
schema: {
|
||||
mcpName: Schema.String,
|
||||
url: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
// Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so
|
||||
// observers can refresh status without polling.
|
||||
export const StatusChanged = Event.ephemeral({
|
||||
|
||||
@@ -44,8 +44,6 @@
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
@@ -37,6 +38,11 @@ type PersistedState = {
|
||||
cwd: Record<string, TabsState>
|
||||
}
|
||||
|
||||
type ScrollAnchor = {
|
||||
messageID: string
|
||||
screenY: number
|
||||
}
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
@@ -52,7 +58,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const config = useConfig().data
|
||||
const location = useLocation()
|
||||
const paths = useTuiPaths()
|
||||
const renderer = useRenderer()
|
||||
const enabled = () => config.tabs.enabled
|
||||
// Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
|
||||
const [focused, setFocused] = createSignal(true)
|
||||
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
||||
// mutating in place, which per-row animations and drag state depend on.
|
||||
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
|
||||
@@ -67,11 +76,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
const scrollPositions = new Map<string, number>()
|
||||
const scrollAnchors = new Map<string, ScrollAnchor>()
|
||||
|
||||
const onFocus = () => setFocused(true)
|
||||
const onBlur = () => setFocused(false)
|
||||
renderer.on("focus", onFocus)
|
||||
renderer.on("blur", onBlur)
|
||||
onCleanup(() => {
|
||||
renderer.off("focus", onFocus)
|
||||
renderer.off("blur", onBlur)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (config.experimental?.tab_scroll === true) return
|
||||
scrollPositions.clear()
|
||||
scrollAnchors.clear()
|
||||
})
|
||||
|
||||
function state() {
|
||||
@@ -125,7 +143,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled()) return
|
||||
if (!enabled() || !focused()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (state().unread[session] === unread) return
|
||||
@@ -145,12 +163,21 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
sessionID,
|
||||
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
if (tabs === state().tabs && !state().unread[sessionID]) return
|
||||
if (tabs === state().tabs) return
|
||||
update((draft) => {
|
||||
draft.tabs = openSessionTab(draft.tabs, {
|
||||
sessionID,
|
||||
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled() || !focused()) return
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const sessionID = root(route.data.sessionID)
|
||||
if (!state().unread[sessionID]) return
|
||||
update((draft) => {
|
||||
delete draft.unread[sessionID]
|
||||
})
|
||||
})
|
||||
@@ -238,7 +265,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
scrollPositions.delete(target)
|
||||
scrollAnchors.delete(target)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = navigate && current() === target
|
||||
if (closed.tabs === state().tabs && !selected) return
|
||||
@@ -270,18 +297,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
},
|
||||
current,
|
||||
status,
|
||||
scrollPosition(sessionID: string) {
|
||||
scrollAnchor(sessionID: string) {
|
||||
const target = root(sessionID)
|
||||
if (!state().tabs.some((tab) => tab.sessionID === target)) return
|
||||
return scrollPositions.get(target)
|
||||
return scrollAnchors.get(target)
|
||||
},
|
||||
setScrollPosition(sessionID: string, position: number | undefined) {
|
||||
setScrollAnchor(sessionID: string, anchor: ScrollAnchor | undefined) {
|
||||
const target = root(sessionID)
|
||||
if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollPositions.delete(target)
|
||||
if (anchor === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollAnchors.delete(target)
|
||||
return
|
||||
}
|
||||
scrollPositions.set(target, position)
|
||||
const current = scrollAnchors.get(target)
|
||||
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
|
||||
scrollAnchors.set(target, anchor)
|
||||
},
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
|
||||
@@ -149,6 +149,7 @@ export function Session() {
|
||||
await writeFile(file, content)
|
||||
}
|
||||
const route = useRouteData("session")
|
||||
const sessionID = route.sessionID
|
||||
const { navigate } = useRoute()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
@@ -266,13 +267,21 @@ export function Session() {
|
||||
})
|
||||
})
|
||||
const editor = useEditorContext()
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
const [rowsSynced, setRowsSynced] = createSignal(false)
|
||||
const rows = createSessionRows(
|
||||
() => route.sessionID,
|
||||
(id) => {
|
||||
if (id === sessionID) setRowsSynced(true)
|
||||
},
|
||||
)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
|
||||
const clearMessageNavigation = () => {
|
||||
setNavigationSlack(0)
|
||||
@@ -318,7 +327,6 @@ export function Session() {
|
||||
return
|
||||
}
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID)
|
||||
setSynced(true)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
@@ -333,13 +341,18 @@ export function Session() {
|
||||
|
||||
let seeded = false
|
||||
let sent = false
|
||||
let restored = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
createEffect(() => {
|
||||
if (restored || !synced() || !rowsSynced() || !scroll || scroll.isDestroyed) return
|
||||
restored = true
|
||||
restoreScrollPosition()
|
||||
})
|
||||
let awayTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onCleanup(() => {
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
sessionTabs.setScrollPosition(
|
||||
route.sessionID,
|
||||
config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined,
|
||||
)
|
||||
saveScrollAnchor()
|
||||
})
|
||||
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
@@ -364,8 +377,10 @@ export function Session() {
|
||||
// mount on demand near the top, keeping inactive tabs cheap to tear down. Until the first chunk
|
||||
// pins the count, the hidden span derives from the row count, so streaming appends remain visible.
|
||||
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
||||
const [visibleRowsEnd, setVisibleRowsEnd] = createSignal<number>()
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||
const visibleEnd = createMemo(() => Math.max(hidden(), Math.min(visibleRowsEnd() ?? rows.length, rows.length)))
|
||||
const visibleRows = createMemo(() => rows.slice(hidden(), visibleEnd()))
|
||||
let revealingOlderRows = false
|
||||
const revealOlderRows = (scrollBy = 0) => {
|
||||
const current = hidden()
|
||||
@@ -387,36 +402,102 @@ export function Session() {
|
||||
})
|
||||
return true
|
||||
}
|
||||
let revealingNewerRows = false
|
||||
const revealNewerRows = (scrollBy = 0) => {
|
||||
const current = visibleEnd()
|
||||
if (
|
||||
revealingNewerRows ||
|
||||
current === rows.length ||
|
||||
!scroll ||
|
||||
scroll.isDestroyed ||
|
||||
scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - scroll.viewport.height
|
||||
)
|
||||
return false
|
||||
revealingNewerRows = true
|
||||
const next = Math.min(rows.length, current + TRANSCRIPT_BACKFILL_CHUNK)
|
||||
setVisibleRowsEnd(next === rows.length ? undefined : next)
|
||||
afterLayout(() => {
|
||||
revealingNewerRows = false
|
||||
scroll.scrollBy(scrollBy)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
return true
|
||||
}
|
||||
/** Message navigation needs the full transcript mounted before walking or jumping. */
|
||||
const ensureAllRows = (continuation: () => void) => {
|
||||
if (hidden() === 0) return continuation()
|
||||
if (hidden() === 0 && visibleEnd() === rows.length) return continuation()
|
||||
setHiddenRows(0)
|
||||
setVisibleRowsEnd(undefined)
|
||||
afterLayout(continuation)
|
||||
}
|
||||
|
||||
function isAwayFromBottom() {
|
||||
if (visibleEnd() < rows.length) return true
|
||||
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
|
||||
}
|
||||
function updateAwayFromBottom() {
|
||||
if (config.experimental?.tab_scroll !== true) return
|
||||
setTimeout(() => {
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = setTimeout(() => {
|
||||
awayTimer = undefined
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
const away = isAwayFromBottom()
|
||||
setAwayFromBottom(away)
|
||||
if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined)
|
||||
saveScrollAnchor()
|
||||
})
|
||||
}
|
||||
function restoreScrollPosition(sessionID: string) {
|
||||
const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined
|
||||
if (position === undefined) {
|
||||
function saveScrollAnchor() {
|
||||
if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
return
|
||||
}
|
||||
let first: { messageID: string; screenY: number } | undefined
|
||||
let anchor: { messageID: string; screenY: number } | undefined
|
||||
for (const child of scroll.getChildren()) {
|
||||
if (!child.id || !boundaryIDs().has(child.id)) continue
|
||||
const item = { messageID: child.id, screenY: child.y - scroll.viewport.y }
|
||||
first ??= item
|
||||
if (item.screenY <= 0) anchor = item
|
||||
}
|
||||
anchor ??= first
|
||||
if (anchor) sessionTabs.setScrollAnchor(sessionID, anchor)
|
||||
else sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
}
|
||||
function restoreScrollPosition() {
|
||||
const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined
|
||||
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
|
||||
if (!anchor || index === -1) {
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
setAwayFromBottom(false)
|
||||
return
|
||||
}
|
||||
ensureAllRows(() => {
|
||||
scroll.scrollTo(position)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
setHiddenRows(Math.max(0, index - TRANSCRIPT_BACKFILL_CHUNK))
|
||||
const end = Math.min(rows.length, index + TRANSCRIPT_BACKFILL_CHUNK)
|
||||
setVisibleRowsEnd(end === rows.length ? undefined : end)
|
||||
scroll.stickyScroll = false
|
||||
const restore = () =>
|
||||
afterLayout(() => {
|
||||
const boundary = scroll.getRenderable(anchor.messageID)
|
||||
if (!boundary) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
scroll.stickyScroll = true
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
setAwayFromBottom(false)
|
||||
return
|
||||
}
|
||||
const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y
|
||||
const target = contentY - anchor.screenY
|
||||
const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
|
||||
if (target > maximum && visibleEnd() < rows.length) {
|
||||
const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK)
|
||||
setVisibleRowsEnd(next === rows.length ? undefined : next)
|
||||
restore()
|
||||
return
|
||||
}
|
||||
scroll.scrollTo(target)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
restore()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -527,20 +608,31 @@ export function Session() {
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = undefined
|
||||
setAwayFromBottom(false)
|
||||
sessionTabs.setScrollPosition(route.sessionID, undefined)
|
||||
sessionTabs.setScrollAnchor(route.sessionID, undefined)
|
||||
setHiddenRows(undefined)
|
||||
setVisibleRowsEnd(undefined)
|
||||
setTimeout(() => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
scroll.stickyScroll = true
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function moveTranscript(delta: number) {
|
||||
clearMessageNavigation()
|
||||
if (delta >= 0 || !revealOlderRows(delta)) {
|
||||
scroll.scrollBy(delta)
|
||||
updateAwayFromBottom()
|
||||
if (delta < 0 && revealOlderRows(delta)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (delta > 0 && revealNewerRows(delta)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
scroll.scrollBy(delta)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
@@ -1084,6 +1176,7 @@ export function Session() {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
onMouseScroll={(event) => {
|
||||
if (event.scroll?.direction === "up" && revealOlderRows()) return
|
||||
if (event.scroll?.direction === "down" && revealNewerRows()) return
|
||||
updateAwayFromBottom()
|
||||
}}
|
||||
>
|
||||
@@ -1110,7 +1203,12 @@ export function Session() {
|
||||
</box>
|
||||
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={toBottom}>
|
||||
<text
|
||||
fg={latestHovered() ? theme.text.default : theme.text.subdued}
|
||||
onMouseOver={() => setLatestHovered(true)}
|
||||
onMouseOut={() => setLatestHovered(false)}
|
||||
onMouseUp={toBottom}
|
||||
>
|
||||
Latest ↓
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -35,7 +35,7 @@ export type SessionRow =
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const config = useConfig()
|
||||
@@ -95,6 +95,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
onSynced?.(id)
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
@@ -142,6 +142,9 @@ async function renderSessionTabs(
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
blur: () => app.renderer.emit("blur"),
|
||||
flush: () => storage.flush(),
|
||||
async destroy() {
|
||||
app.renderer.destroy()
|
||||
await storage.flush()
|
||||
@@ -150,6 +153,14 @@ async function renderSessionTabs(
|
||||
}
|
||||
}
|
||||
|
||||
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
|
||||
id: `evt_done_${sessionID}`,
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
|
||||
test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
let release!: () => void
|
||||
const sessionGate = new Promise<void>((resolve) => (release = resolve))
|
||||
@@ -202,6 +213,46 @@ test("stores session tabs for the current working directory by default", async (
|
||||
}
|
||||
})
|
||||
|
||||
test("only the foreground TUI mutates unread state", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
try {
|
||||
foreground = await renderSessionTabs("first", { state: temporary.path })
|
||||
background = await renderSessionTabs("second", { state: temporary.path })
|
||||
foreground.focus()
|
||||
background.blur()
|
||||
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2)
|
||||
|
||||
const firstDone = executionSucceeded("first")
|
||||
foreground.emit(firstDone)
|
||||
background.emit(firstDone)
|
||||
await Promise.all([foreground.flush(), background.flush()])
|
||||
expect(foreground.tabs.status("first").unread).toBeUndefined()
|
||||
expect(background.tabs.status("first").unread).toBeUndefined()
|
||||
|
||||
const secondDone = executionSucceeded("second")
|
||||
foreground.emit(secondDone)
|
||||
background.emit(secondDone)
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === "activity" &&
|
||||
background?.tabs.status("second").unread === "activity",
|
||||
)
|
||||
|
||||
foreground.tabs.select("second")
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === undefined &&
|
||||
background?.tabs.status("second").unread === undefined,
|
||||
)
|
||||
} finally {
|
||||
if (foreground) await foreground.destroy()
|
||||
if (background) await background.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const state = temporary.path
|
||||
|
||||
@@ -82,8 +82,6 @@
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
@@ -97,8 +95,6 @@
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "שינויים בהפעלה",
|
||||
"ui.sessionReview.title.git": "שינויים ב-Git",
|
||||
"ui.sessionReview.title.branch": "שינויים בענף",
|
||||
"ui.sessionReview.title.lastTurn": "שינויים בתור האחרון",
|
||||
"ui.sessionReview.diffStyle.unified": "מאוחד",
|
||||
"ui.sessionReview.diffStyle.split": "מפוצל",
|
||||
"ui.sessionReview.expandAll": "הרחב הכל",
|
||||
"ui.sessionReview.collapseAll": "כווץ הכל",
|
||||
"ui.sessionReview.change.added": "נוסף",
|
||||
"ui.sessionReview.change.removed": "הוסר",
|
||||
"ui.sessionReview.change.modified": "שונה",
|
||||
"ui.sessionReview.image.loading": "טוען...",
|
||||
"ui.sessionReview.image.placeholder": "תמונה",
|
||||
"ui.sessionReview.largeDiff.title": "ה-diff גדול מדי להצגה",
|
||||
"ui.sessionReview.largeDiff.meta": "המגבלה היא {{limit}} שורות שהשתנו. כעת יש {{current}} שורות שהשתנו.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "הצג בכל זאת",
|
||||
"ui.sessionReviewV2.expandMode": "הרחבה או כיווץ של ה-diff",
|
||||
"ui.sessionReviewV2.filterFiles": "סינון קבצים",
|
||||
"ui.sessionReviewV2.toggleSidebar": "הצגה או הסתרה של עץ הקבצים",
|
||||
"ui.sessionReviewV2.showAllLines": "הצג את כל השורות",
|
||||
"ui.sessionReviewV2.hideNonDiffLines": "הסתרת שורות שאינן חלק מה-diff",
|
||||
"ui.sessionReviewV2.unifiedDiff": "diff מאוחד",
|
||||
"ui.sessionReviewV2.splitDiff": "diff מפוצל",
|
||||
"ui.sessionReviewV2.previousFile": "הקובץ הקודם",
|
||||
"ui.sessionReviewV2.nextFile": "הקובץ הבא",
|
||||
"ui.sessionReviewV2.diffView": "תצוגת diff",
|
||||
"ui.sessionReviewV2.empty.noGit.title": "אין שינויים במעקב",
|
||||
"ui.sessionReviewV2.empty.noGit.description": "עקוב, סקור ובטל שינויים בפרויקט הזה",
|
||||
"ui.sessionReviewV2.empty.noGit.action": "צור מאגר Git",
|
||||
"ui.sessionReviewV2.empty.noGit.actionLoading": "יוצר מאגר Git...",
|
||||
"ui.sessionReviewV2.empty.changes.title": "אין עדיין שינויים בקובץ",
|
||||
"ui.sessionReviewV2.empty.changes.description": "שינויים בפרויקט יופיעו כאן",
|
||||
"ui.sessionReview.openFile": "פתיחת קובץ",
|
||||
"ui.sessionReview.selection.line": "שורה {{line}}",
|
||||
"ui.sessionReview.selection.lines": "שורות {{start}}-{{end}}",
|
||||
"ui.fileMedia.kind.image": "תמונה",
|
||||
"ui.fileMedia.kind.audio": "אודיו",
|
||||
"ui.fileMedia.state.removed": "הוסר קובץ {{kind}}.",
|
||||
"ui.fileMedia.state.loading": "טוען {{kind}}...",
|
||||
"ui.fileMedia.state.error": "לא ניתן לטעון {{kind}}.",
|
||||
"ui.fileMedia.state.unavailable": "תצוגה מקדימה של {{kind}} אינה זמינה.",
|
||||
"ui.fileMedia.binary.title": "קובץ בינארי",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} הוא קובץ בינארי.",
|
||||
"ui.fileMedia.binary.description.default": "תוכן בינארי",
|
||||
"ui.lineComment.label.prefix": "תגובה על ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
"ui.lineComment.editorLabel.prefix": "תגובה על ",
|
||||
"ui.lineComment.editorLabel.suffix": "",
|
||||
"ui.lineComment.placeholder": "הוסף תגובה",
|
||||
"ui.lineComment.contextPlaceholder": "הוסף הקשר לשינוי זה",
|
||||
"ui.lineComment.submit": "שלח תגובה",
|
||||
"ui.lineComment.cancel": "בטל",
|
||||
"ui.sessionTurn.steps.show": "הצג צעדים",
|
||||
"ui.sessionTurn.steps.hide": "הסתר צעדים",
|
||||
"ui.sessionTurn.summary.response": "תגובה",
|
||||
"ui.sessionTurn.diff.showMore": "הצג שינויים נוספים ({{count}})",
|
||||
"ui.sessionTurn.diffs.changed.one": "{{count}} קובץ השתנה",
|
||||
"ui.sessionTurn.diffs.changed.other": "{{count}} קבצים השתנו",
|
||||
"ui.sessionTurn.diffs.showAll": "הצג הכל",
|
||||
"ui.sessionTurn.diffs.showLess": "הצג פחות",
|
||||
"ui.sessionTurn.diffs.more": "+{{count}} קבצים נוספים",
|
||||
"ui.sessionTurn.retry.retrying": "מנסה שוב",
|
||||
"ui.sessionTurn.retry.inSeconds": "בעוד {{seconds}} שניות",
|
||||
"ui.sessionTurn.retry.attempt": "ניסיון #{{attempt}}",
|
||||
"ui.sessionTurn.retry.attemptLine": "{{line}} - ניסיון #{{attempt}}",
|
||||
"ui.sessionTurn.retry.geminiHot": "העומס על Gemini גבוה מדי כרגע",
|
||||
"ui.sessionTurn.error.freeUsageExceeded": "חריגה מהשימוש בחינם",
|
||||
"ui.sessionTurn.error.addCredits": "הוסף קרדיטים",
|
||||
"dialog.usageExceeded.freeTier.title": "הגעת למגבלה החינמית",
|
||||
"dialog.usageExceeded.freeTier.description":
|
||||
"הירשם ל-OpenCode Go לקבלת גישה אמינה למודלי הקוד הפתוח הטובים ביותר, החל מ-$5 לחודש.",
|
||||
"dialog.usageExceeded.freeTier.actionLabel": "הירשם",
|
||||
"dialog.usageExceeded.accountRateLimit.title": "הגעת למגבלת Go",
|
||||
"dialog.usageExceeded.accountRateLimit.description":
|
||||
"הגעת למגבלת השימוש. כדי להמשיך להשתמש במודל זה כעת, הפעל שימוש מהיתרה הזמינה שלך",
|
||||
"dialog.usageExceeded.accountRateLimit.actionLabel": "פתח את ההגדרות",
|
||||
"ui.sessionTurn.status.delegating": "מעביר את העבודה לסוכן אחר",
|
||||
"ui.sessionTurn.status.planning": "תכנון השלבים הבאים",
|
||||
"ui.sessionTurn.status.gatheringContext": "בודק את הפרויקט",
|
||||
"ui.sessionTurn.status.gatheredContext": "בדיקת הפרויקט הסתיימה",
|
||||
"ui.sessionTurn.status.searchingCodebase": "חיפוש בבסיס הקוד",
|
||||
"ui.sessionTurn.status.searchingWeb": "חיפוש באינטרנט",
|
||||
"ui.sessionTurn.status.makingEdits": "ביצוע עריכות",
|
||||
"ui.sessionTurn.status.runningCommands": "הפעלת פקודות",
|
||||
"ui.sessionTurn.status.thinking": "חושב",
|
||||
"ui.sessionTurn.status.thinkingWithTopic": "חשיבה - {{topic}}",
|
||||
"ui.sessionTurn.status.gatheringThoughts": "מגבש מחשבות",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "בוחן את הצעדים הבאים",
|
||||
"ui.messagePart.diagnostic.error": "שגיאה",
|
||||
"ui.messagePart.title.edit": "ערוך",
|
||||
"ui.messagePart.title.write": "כתוב",
|
||||
"ui.messagePart.option.typeOwnAnswer": "הקלד את התשובה שלך",
|
||||
"ui.messagePart.review.title": "בדיקת התשובות",
|
||||
"ui.messagePart.questions.dismissed": "השאלות נדחו",
|
||||
"ui.messagePart.compaction": "ההפעלה נדחסה",
|
||||
"ui.messagePart.context.read.one": "{{count}} קריאה",
|
||||
"ui.messagePart.context.read.other": "{{count}} קריאות",
|
||||
"ui.messagePart.context.search.one": "{{count}} חיפוש",
|
||||
"ui.messagePart.context.search.other": "{{count}} חיפושים",
|
||||
"ui.messagePart.context.list.one": "{{count}} הצגה",
|
||||
"ui.messagePart.context.list.other": "{{count}} הצגות",
|
||||
"ui.list.loading": "טוען",
|
||||
"ui.list.empty": "אין תוצאות",
|
||||
"ui.list.clearFilter": "נקה מסנן",
|
||||
"ui.list.emptyWithFilter.prefix": "אין תוצאות עבור",
|
||||
"ui.list.emptyWithFilter.suffix": "",
|
||||
"ui.fileSearch.placeholder": "מצא",
|
||||
"ui.fileSearch.previousMatch": "התוצאה הקודמת",
|
||||
"ui.fileSearch.nextMatch": "התוצאה הבאה",
|
||||
"ui.fileSearch.close": "סגור חיפוש",
|
||||
"ui.messageNav.newMessage": "הודעה חדשה",
|
||||
"ui.promptInput.noMatchingItems": "אין פריטים תואמים",
|
||||
"ui.promptInput.commands": "פקודות",
|
||||
"ui.promptInput.dropFiles": "שחרר קבצים לצירוף",
|
||||
"ui.promptInput.removeAttachment": "הסר את הקובץ המצורף",
|
||||
"ui.promptInput.label": "פרומפט",
|
||||
"ui.promptInput.placeholder.shell": "הזן פקודת מעטפת...",
|
||||
"ui.promptInput.placeholder.normal": "שאל כל דבר, {{slash}} עבור פקודות, {{at}} עבור הקשר...",
|
||||
"ui.promptInput.add": "הוסף תמונות וקבצים",
|
||||
"ui.promptInput.attachments": "תמונות וקבצים",
|
||||
"ui.promptInput.context": "הקשר",
|
||||
"ui.promptInput.shell": "פקודת מעטפת",
|
||||
"ui.promptInput.chooseAgent": "בחר סוכן",
|
||||
"ui.promptInput.chooseModel": "בחר מודל",
|
||||
"ui.promptInput.chooseVariant": "בחר גרסה של מודל",
|
||||
"ui.promptInput.send": "שלח",
|
||||
"ui.promptInput.stop": "עצור",
|
||||
"ui.tabs.close": "סגור כרטיסייה",
|
||||
"ui.textField.copyToClipboard": "העתק ללוח",
|
||||
"ui.textField.copyLink": "העתק קישור",
|
||||
"ui.textField.copied": "הועתק",
|
||||
"ui.imagePreview.alt": "תצוגה מקדימה של תמונה",
|
||||
"ui.scrollView.ariaLabel": "תוכן שניתן לגלול",
|
||||
"ui.tool.read": "קרא",
|
||||
"ui.tool.loaded": "טעון",
|
||||
"ui.tool.list": "רשימה",
|
||||
"ui.tool.glob": "Glob",
|
||||
"ui.tool.grep": "Grep",
|
||||
"ui.tool.task": "משימה",
|
||||
"ui.tool.webfetch": "Webfetch",
|
||||
"ui.tool.websearch": "Web Search",
|
||||
"ui.tool.websearch.provider": "{{provider}} Web Search",
|
||||
"ui.tool.shell": "Shell",
|
||||
"ui.tool.patch": "Patch",
|
||||
"ui.tool.questions": "שאלות",
|
||||
"ui.tool.questions.numbered": "שאלות {{number}}",
|
||||
"ui.tool.agent": "סוכן {{type}}",
|
||||
"ui.tool.agent.default": "סוכן",
|
||||
"ui.tool.skill": "מיומנות",
|
||||
"ui.basicTool.called": "בוצעה קריאה אל `{{tool}}`",
|
||||
"ui.toolErrorCard.failed": "נכשל",
|
||||
"ui.toolErrorCard.copyError": "שגיאת העתקה",
|
||||
"ui.common.file.one": "קובץ",
|
||||
"ui.common.file.other": "קבצים",
|
||||
"ui.common.question.one": "שאלה",
|
||||
"ui.common.question.other": "שאלות",
|
||||
"ui.common.add": "הוסף",
|
||||
"ui.common.clear": "נקה",
|
||||
"ui.common.file": "קובץ",
|
||||
"ui.common.back": "חזרה",
|
||||
"ui.common.cancel": "בטל",
|
||||
"ui.common.confirm": "אשר",
|
||||
"ui.common.dismiss": "סגור",
|
||||
"ui.common.close": "סגור",
|
||||
"ui.common.next": "הבא",
|
||||
"ui.common.submit": "שלח",
|
||||
"ui.common.showMore": "הצג עוד",
|
||||
"ui.permission.deny": "דחה",
|
||||
"ui.permission.allowAlways": "אפשר תמיד",
|
||||
"ui.permission.allowOnce": "אפשר פעם אחת",
|
||||
"ui.message.expand": "הרחב את ההודעה",
|
||||
"ui.message.collapse": "כווץ הודעה",
|
||||
"ui.message.copy": "העתק",
|
||||
"ui.message.copyMessage": "העתק הודעה",
|
||||
"ui.message.forkMessage": "יצירת הפעלה חדשה מכאן",
|
||||
"ui.message.revertMessage": "ביטול ההודעה",
|
||||
"ui.message.copyResponse": "העתק את התגובה",
|
||||
"ui.message.copied": "הועתק",
|
||||
"ui.message.duration.seconds": "{{count}}s",
|
||||
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"ui.message.interrupted": "נקטע",
|
||||
"ui.message.queued": "בתור",
|
||||
"ui.message.attachment.alt": "קובץ מצורף",
|
||||
"ui.patch.action.deleted": "נמחק",
|
||||
"ui.patch.action.created": "נוצר",
|
||||
"ui.patch.action.moved": "הועבר",
|
||||
"ui.patch.action.patched": "עודכן",
|
||||
"ui.question.subtitle.answered": "שאלות שנענו: {{count}}",
|
||||
"ui.question.answer.none": "(אין תשובה)",
|
||||
"ui.question.review.notAnswered": "(ללא תשובה)",
|
||||
"ui.question.multiHint": "בחר את כל התשובות המתאימות",
|
||||
"ui.question.singleHint": "בחר תשובה אחת",
|
||||
"ui.question.custom.placeholder": "הקלד את תשובתך...",
|
||||
"ui.sessionTurn.diffs.changed.two": "{{count}} קבצים השתנו",
|
||||
"ui.messagePart.context.read.two": "{{count}} קריאות",
|
||||
"ui.messagePart.context.search.two": "{{count}} חיפושים",
|
||||
"ui.messagePart.context.list.two": "{{count}} הצגות",
|
||||
}
|
||||
@@ -52,8 +52,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"resolve.exports": "catalog:",
|
||||
"xdg-basedir": "5.1.0"
|
||||
"resolve.exports": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { xdgCache, xdgConfig, xdgData, xdgState } from "xdg-basedir"
|
||||
|
||||
const home = os.homedir()
|
||||
const data = process.env.XDG_DATA_HOME || (home ? path.join(home, ".local", "share") : undefined)
|
||||
const cache = process.env.XDG_CACHE_HOME || (home ? path.join(home, ".cache") : undefined)
|
||||
const config = process.env.XDG_CONFIG_HOME || (home ? path.join(home, ".config") : undefined)
|
||||
const state = process.env.XDG_STATE_HOME || (home ? path.join(home, ".local", "state") : undefined)
|
||||
|
||||
/** The XDG base directories that root opencode's global paths. */
|
||||
export function roots(app: string) {
|
||||
return {
|
||||
data: path.join(xdgData!, app),
|
||||
cache: path.join(xdgCache!, app),
|
||||
config: path.join(xdgConfig!, app),
|
||||
state: path.join(xdgState!, app),
|
||||
data: path.join(data!, app),
|
||||
cache: path.join(cache!, app),
|
||||
config: path.join(config!, app),
|
||||
state: path.join(state!, app),
|
||||
tmp: path.join(os.tmpdir(), app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
export * as NpmConfig from "./npm-config.js"
|
||||
|
||||
import { fileURLToPath } from "url"
|
||||
// @ts-expect-error npm does not publish types for this internal config API.
|
||||
import Config from "@npmcli/config"
|
||||
// @ts-expect-error npm does not publish types for this internal config API.
|
||||
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const load = (dir: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
// @ts-expect-error npm does not publish types for this internal config API.
|
||||
const { default: Config } = await import("@npmcli/config")
|
||||
// @ts-expect-error npm does not publish types for this internal config API.
|
||||
const { default: npmDefinitions } = await import("@npmcli/config/lib/definitions/index.js")
|
||||
const { definitions, flatten, nerfDarts, shorthands } = npmDefinitions
|
||||
const config = new Config({
|
||||
// Resolved per call: on workerd import.meta.url is undefined and building
|
||||
// this URL at module scope fails startup validation; npm config never runs there.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Npm from "./npm.js"
|
||||
|
||||
import path from "path"
|
||||
import npa from "npm-package-arg"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
||||
import { FSUtil } from "./fs-util.js"
|
||||
@@ -30,15 +29,6 @@ export interface Interface {
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly install: (
|
||||
dir: string,
|
||||
input?: {
|
||||
add: {
|
||||
name: string
|
||||
version?: string
|
||||
}[]
|
||||
},
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -120,6 +110,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
const dir = directory(pkg)
|
||||
const name = (() => {
|
||||
try {
|
||||
@@ -143,59 +134,6 @@ const layer = Layer.effect(
|
||||
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||
}, Effect.scoped)
|
||||
|
||||
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
|
||||
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!canWrite) return
|
||||
|
||||
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
|
||||
if (
|
||||
yield* Effect.gen(function* () {
|
||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||
if (!nodeModulesExists) {
|
||||
yield* reify({ add, dir })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||
)
|
||||
return
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
|
||||
const pkgAny = pkg as any
|
||||
const lockAny = lock as any
|
||||
const declared = new Set([
|
||||
...Object.keys(pkgAny?.dependencies || {}),
|
||||
...Object.keys(pkgAny?.devDependencies || {}),
|
||||
...Object.keys(pkgAny?.peerDependencies || {}),
|
||||
...Object.keys(pkgAny?.optionalDependencies || {}),
|
||||
...(input?.add || []).map((pkg) => pkg.name),
|
||||
])
|
||||
|
||||
const root = lockAny?.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root?.dependencies || {}),
|
||||
...Object.keys(root?.devDependencies || {}),
|
||||
...Object.keys(root?.peerDependencies || {}),
|
||||
...Object.keys(root?.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
yield* reify({ dir, add })
|
||||
return
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
||||
|
||||
return
|
||||
}, Effect.scoped)
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
@@ -249,7 +187,6 @@ const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
install,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
@@ -263,10 +200,6 @@ export const node = makeGlobalNode({
|
||||
|
||||
const { runPromise } = makeRuntime(Service, LayerNode.compile(node))
|
||||
|
||||
export async function install(...args: Parameters<Interface["install"]>) {
|
||||
return runPromise((svc) => svc.install(...args))
|
||||
}
|
||||
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
|
||||
const module = pathToFileURL(path.join(import.meta.dir, "../src/global-roots.ts")).href
|
||||
|
||||
describe("global roots", () => {
|
||||
test("uses XDG overrides", () => {
|
||||
const root = path.join(os.tmpdir(), "opencode-xdg-overrides")
|
||||
const env = {
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
|
||||
expect(run(env)).toEqual({
|
||||
data: path.join(env.XDG_DATA_HOME, "opencode"),
|
||||
cache: path.join(env.XDG_CACHE_HOME, "opencode"),
|
||||
config: path.join(env.XDG_CONFIG_HOME, "opencode"),
|
||||
state: path.join(env.XDG_STATE_HOME, "opencode"),
|
||||
tmp: path.join(os.tmpdir(), "opencode"),
|
||||
})
|
||||
})
|
||||
|
||||
test("empty XDG overrides use home directory defaults", () => {
|
||||
const home = path.join(os.tmpdir(), "opencode-xdg-home")
|
||||
|
||||
expect(
|
||||
run({
|
||||
XDG_DATA_HOME: "",
|
||||
XDG_CACHE_HOME: "",
|
||||
XDG_CONFIG_HOME: "",
|
||||
XDG_STATE_HOME: "",
|
||||
...(process.platform === "win32" ? { USERPROFILE: home } : { HOME: home }),
|
||||
}),
|
||||
).toEqual({
|
||||
data: path.join(home, ".local", "share", "opencode"),
|
||||
cache: path.join(home, ".cache", "opencode"),
|
||||
config: path.join(home, ".config", "opencode"),
|
||||
state: path.join(home, ".local", "state", "opencode"),
|
||||
tmp: path.join(os.tmpdir(), "opencode"),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function run(env: Record<string, string>) {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
`const { roots } = await import(${JSON.stringify(module)}); console.log(JSON.stringify(roots("opencode")))`,
|
||||
],
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||
return JSON.parse(result.stdout.toString())
|
||||
}
|
||||
@@ -24,7 +24,6 @@
|
||||
"@cloudflare/workers-types": "^4.20250808.0",
|
||||
"vitest": "3.2.7",
|
||||
"wrangler": "4.28.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"@effect/platform-node": "catalog:"
|
||||
}
|
||||
|
||||
@@ -51,10 +51,8 @@ export default defineWorkersConfig({
|
||||
// mime-types requires mime-db's JSON database at require time; keep the
|
||||
// lookup surface but back it with a static shim.
|
||||
{ find: /^mime-types$/, replacement: new URL("./test/shims/mime-types.mjs", import.meta.url).pathname },
|
||||
// util/npm.ts imports the npm toolchain at module scope; plugin installs
|
||||
// never happen in the workerd profile (plugin discovery is precompiled-only),
|
||||
// and @npmcli/config touches process.stdout.isTTY during module init.
|
||||
{ find: /^@npmcli\/config(\/.*)?$/, replacement: mockProxy },
|
||||
// Plugin installs never happen in the workerd profile (plugin discovery
|
||||
// is precompiled-only), so mock package installation when it is loaded.
|
||||
{ find: /^@npmcli\/arborist(\/.*)?$/, replacement: mockProxy },
|
||||
{ find: /^pacote(\/.*)?$/, replacement: mockProxy },
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
github: {
|
||||
owner: "anomalyco",
|
||||
repo: "opencode",
|
||||
branch: "dev",
|
||||
branch: "v2",
|
||||
dir: "packages/www",
|
||||
},
|
||||
theme: {
|
||||
|
||||
@@ -75,10 +75,16 @@ describe("translate app", () => {
|
||||
"packages/ui/src/i18n/dv.ts",
|
||||
"packages/desktop/src/renderer/i18n/dv.ts",
|
||||
])
|
||||
expect(targetFiles("he")).toEqual([
|
||||
"packages/app/src/i18n/he.ts",
|
||||
"packages/ui/src/i18n/he.ts",
|
||||
"packages/desktop/src/renderer/i18n/he.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("maps product locale codes to their glossaries", () => {
|
||||
expect(glossaryFile("fr")).toBe(".opencode/glossary/fr.md")
|
||||
expect(glossaryFile("he")).toBe(".opencode/glossary/he.md")
|
||||
expect(glossaryFile("zh")).toBe(".opencode/glossary/zh-cn.md")
|
||||
expect(glossaryFile("zht")).toBe(".opencode/glossary/zh-tw.md")
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ const locales = DESKTOP_NATIVE_LOCALES.filter((locale): locale is Locale => loca
|
||||
|
||||
const languages = {
|
||||
ar: "Arabic",
|
||||
he: "Hebrew",
|
||||
br: "Brazilian Portuguese",
|
||||
bs: "Bosnian",
|
||||
da: "Danish",
|
||||
|
||||
Reference in New Issue
Block a user