Compare commits

...

17 Commits

Author SHA1 Message Date
Kit Langton a1fce85b76 refactor(cli): replace updater semver dependency 2026-08-13 23:09:52 -04:00
Kit Langton fec4f20736 perf(core): load MCP client lazily (#42468) 2026-08-13 22:48:09 -04:00
opencode-agent[bot] 49d07ffe5f fix(core): use file times for tool output cleanup (#42450)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-13 21:44:16 -05:00
opencode-agent[bot] 28f6968dda fix(www): point edit links to v2 (#42472)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-13 21:42:46 -05:00
opencode-agent[bot] 979ac810af feat(app): add Hebrew locale (#42475)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-14 12:31:48 +10:00
Kit Langton 71caa7d406 fix(tui): isolate tab scroll state (#42456) 2026-08-13 22:04:40 -04:00
Kit Langton 82b96eb495 fix(tui): scope unread updates to focused terminal (#42471) 2026-08-13 21:54:56 -04:00
Kit Langton 7909817fba chore(app): remove stale frontend dependencies (#42464) 2026-08-13 21:52:59 -04:00
Kit Langton 23b7632743 refactor(core): remove bus replay all (#42460) 2026-08-13 21:52:48 -04:00
Kit Langton de28ca9f0e refactor(util): load npm package parser lazily (#42467) 2026-08-13 21:48:48 -04:00
Kit Langton 9d9e57941c chore(ui): remove stale motion pins (#42465) 2026-08-13 21:48:30 -04:00
Kit Langton 0e28d651a5 refactor(core): remove unused API members (#42463) 2026-08-13 21:48:12 -04:00
Kit Langton a5b6ead43e perf(util): load npm config lazily (#42458) 2026-08-13 21:19:32 -04:00
Kit Langton 7a73aad0a2 chore: remove orphaned v2 exports (#42459) 2026-08-13 21:18:58 -04:00
Kit Langton 304c85fb2b refactor(util): remove xdg-basedir dependency (#42462) 2026-08-13 21:18:48 -04:00
Kit Langton 85988b957b refactor(core): trim sqlite adapter paths (#42457) 2026-08-13 21:18:05 -04:00
Kit Langton 146aeef830 refactor(util): remove unused npm install (#42454) 2026-08-13 21:17:52 -04:00
54 changed files with 2045 additions and 595 deletions
+48
View File
@@ -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`.
+7 -69
View File
@@ -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",
@@ -143,7 +137,6 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"uqr": "0.1.3",
"ws": "8.21.0",
@@ -169,7 +162,6 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
@@ -736,8 +728,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 +950,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 +962,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 +1024,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 +1089,6 @@
"unenv": "2.0.0-rc.24",
"vitest": "3.2.7",
"wrangler": "4.28.0",
"xdg-basedir": "5.1.0",
},
},
"packages/www": {
@@ -2745,7 +2729,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 +2743,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 +2865,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 +2889,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 +4891,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 +5971,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=="],
@@ -6457,8 +6427,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
@@ -6493,8 +6461,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 +6485,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 +6545,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 +6943,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 +7609,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 +7687,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 +7727,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 +7979,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=="],
-6
View File
@@ -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",
+2 -1
View File
@@ -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", () => {
+3
View File
@@ -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
-2
View File
@@ -38,7 +38,6 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"uqr": "0.1.3",
"ws": "8.21.0"
@@ -48,7 +47,6 @@
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
@@ -0,0 +1,50 @@
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return "upgrade"
}
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
if ([match[1], match[2], match[3]].some(invalidComponent)) return
if (
match[4]
?.split(".")
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
)
return
return {
major: match[1],
core: `${match[1]}.${match[2]}.${match[3]}`,
prerelease: match[4]?.split(".") ?? [],
}
}
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
return current.prerelease.every((identifier, index) => {
const other = latest.prerelease[index]
if (identifier === other) return true
// semver compares oversized numeric prerelease identifiers after numeric coercion.
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
})
}
function invalidComponent(value: string) {
if (value.length > 1 && value.startsWith("0")) return true
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
return value > maximumComponent
}
+46 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { action, decodePolicy } from "./updater"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
@@ -30,4 +31,48 @@ describe("updater", () => {
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
})
test("preserves strict validity", () => {
const invalid = [
"=1.2.3",
"V1.2.3",
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-01",
"1.2.3-",
"1.2.3+",
"1.2.3-alpha..1",
"1.2.3_alpha",
"9007199254740992.0.0",
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
})
})
+2 -11
View File
@@ -5,12 +5,10 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"
import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName =
@@ -34,14 +32,6 @@ export function decodePolicy(text: string): Policy | undefined {
if (typeof value === "boolean" || value === "notify") return value
}
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
// Major upgrades are never installed automatically.
if (semver.major(latest) !== semver.major(current)) return "none"
return "upgrade"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -166,3 +156,4 @@ export const layer = Layer.effect(
)
export * as Updater from "./updater"
export { action, type Action, type Policy } from "./updater-action"
-27
View File
@@ -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)
}
})
},
}
-2
View File
@@ -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) {
-12
View File
@@ -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)) {
+1 -8
View File
@@ -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)
+8 -3
View File
@@ -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(
+14 -6
View File
@@ -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(
-9
View File
@@ -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
-82
View File
@@ -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
-1
View File
@@ -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,
})
}),
)
+5 -1
View File
@@ -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 })
}
})
+43 -1
View File
@@ -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"))
-1
View File
@@ -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),
})
+2 -2
View File
@@ -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" },
+3 -1
View File
@@ -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({
+6 -2
View File
@@ -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(),
+4 -3
View File
@@ -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)
+20
View File
@@ -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])
-8
View File
@@ -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({
-2
View File
@@ -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",
+40 -11
View File
@@ -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
+120 -22
View File
@@ -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>
+2 -1
View File
@@ -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
-4
View File
@@ -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:",
+199
View File
@@ -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}} הצגות",
}
+1 -2
View File
@@ -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:",
+10 -5
View File
@@ -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),
}
}
+5 -4
View File
@@ -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 -68
View File
@@ -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))
}
+62
View File
@@ -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())
}
-1
View File
@@ -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:"
}
+2 -4
View File
@@ -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 },
],
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
github: {
owner: "anomalyco",
repo: "opencode",
branch: "dev",
branch: "v2",
dir: "packages/www",
},
theme: {
+6
View File
@@ -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")
})
+1
View File
@@ -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",