mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 23:09:50 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05e9818285 |
@@ -1,48 +0,0 @@
|
||||
# he Glossary
|
||||
|
||||
## Sources
|
||||
|
||||
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
|
||||
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
|
||||
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
|
||||
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
|
||||
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
|
||||
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
|
||||
|
||||
## Do Not Translate (Locale Additions)
|
||||
|
||||
- `OpenCode` (preserve casing in prose and UI copy)
|
||||
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
|
||||
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
|
||||
- Keep `commit` and `diff` when they name the exact Git artifact or operation
|
||||
|
||||
## Preferred Terms
|
||||
|
||||
| English / Context | Preferred | Notes |
|
||||
| ----------------- | ------------- | ------------------------------------------------------------------------- |
|
||||
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
|
||||
| workspace | `סביבת עבודה` | |
|
||||
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
|
||||
| command | `פקודה` | |
|
||||
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
|
||||
| model | `מודל` | |
|
||||
| API key | `מפתח API` | Keep the acronym in Latin letters |
|
||||
| plugin | `תוסף` | |
|
||||
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
|
||||
| branch | `ענף` | |
|
||||
| context | `הקשר` | Use `חלון הקשר` for context window |
|
||||
| tokens | `אסימונים` | |
|
||||
|
||||
## Guidance
|
||||
|
||||
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
|
||||
- Use short action verbs for controls and translate complete phrases in context.
|
||||
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
|
||||
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
|
||||
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
|
||||
- Avoid translating `commit` as `התחייבות`.
|
||||
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
|
||||
@@ -70,13 +70,19 @@
|
||||
"@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",
|
||||
@@ -730,6 +736,8 @@
|
||||
"@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",
|
||||
@@ -952,6 +960,8 @@
|
||||
"@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",
|
||||
@@ -964,6 +974,8 @@
|
||||
"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:",
|
||||
@@ -2731,7 +2743,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@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/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/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=="],
|
||||
|
||||
@@ -2745,11 +2757,11 @@
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
"@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/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@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
"@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/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
@@ -2867,6 +2879,12 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -2891,14 +2909,20 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4893,9 +4917,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.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
@@ -6465,6 +6489,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6489,6 +6515,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6549,9 +6577,17 @@
|
||||
|
||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"@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-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/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@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/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=="],
|
||||
|
||||
@@ -6947,6 +6983,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7613,6 +7653,10 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7691,6 +7735,10 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7731,6 +7779,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7983,6 +8033,10 @@
|
||||
|
||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
@@ -63,13 +63,19 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
export type Locale = DesktopNativeLocale
|
||||
export type Direction = "ltr" | "rtl"
|
||||
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "he", "ur", "pa", "fa", "dv"])
|
||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
|
||||
|
||||
function localeDirection(locale: Locale): Direction {
|
||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||
@@ -73,7 +73,6 @@ 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,7 +29,6 @@ describe("desktop native translations", () => {
|
||||
"Українська",
|
||||
"Bosanski",
|
||||
"العربية",
|
||||
"עברית",
|
||||
"Norsk",
|
||||
"Português (Brasil)",
|
||||
"ไทย",
|
||||
@@ -134,11 +133,6 @@ describe("desktop native locale detection", () => {
|
||||
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
|
||||
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
|
||||
})
|
||||
|
||||
test("recognizes Hebrew language tags", () => {
|
||||
expect(detectDesktopNativeLocale(["he"])).toBe("he")
|
||||
expect(detectDesktopNativeLocale(["he-IL"])).toBe("he")
|
||||
})
|
||||
})
|
||||
|
||||
describe("desktop native ICU data", () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ export const DESKTOP_NATIVE_LOCALES = [
|
||||
"uk",
|
||||
"bs",
|
||||
"ar",
|
||||
"he",
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
@@ -81,7 +80,6 @@ export const DESKTOP_NATIVE_LABELS: Record<DesktopNativeLocale, string> = {
|
||||
uk: "Українська",
|
||||
bs: "Bosanski",
|
||||
ar: "العربية",
|
||||
he: "עברית",
|
||||
no: "Norsk",
|
||||
br: "Português (Brasil)",
|
||||
th: "ไทย",
|
||||
@@ -147,7 +145,6 @@ export const DESKTOP_NATIVE_LOCALE_TAGS: Record<DesktopNativeLocale, string> = {
|
||||
uk: "uk",
|
||||
bs: "bs",
|
||||
ar: "ar",
|
||||
he: "he-IL",
|
||||
no: "nb-NO",
|
||||
br: "pt-BR",
|
||||
th: "th",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,6 +158,10 @@ 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>
|
||||
}
|
||||
@@ -644,6 +648,28 @@ 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(() =>
|
||||
@@ -840,6 +866,7 @@ export function configured(options?: Options) {
|
||||
listen,
|
||||
project,
|
||||
replay,
|
||||
replayAll,
|
||||
remove,
|
||||
claim,
|
||||
})
|
||||
|
||||
@@ -223,6 +223,8 @@ 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) {
|
||||
|
||||
@@ -89,6 +89,7 @@ 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>
|
||||
@@ -186,6 +187,12 @@ 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
|
||||
@@ -353,7 +360,7 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
|
||||
return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel })
|
||||
})
|
||||
|
||||
const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -19,7 +19,8 @@ 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 type { MCPClient } from "./client.js"
|
||||
import { MCPClient } from "./client.js"
|
||||
import { MCPOAuth } from "./oauth.js"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export type ServerName = typeof ServerName.Type
|
||||
@@ -244,11 +245,7 @@ export const layer = (options?: Options) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: name },
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
|
||||
}),
|
||||
authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
|
||||
})
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
@@ -267,7 +264,6 @@ 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 = {
|
||||
@@ -509,7 +505,6 @@ export const layer = (options?: Options) =>
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ToolOutput from "./tool-output.js"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, Layer, 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,23 +24,15 @@ 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 = Date.now() - Duration.toMillis(RETENTION)
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", "ascending", 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([])),
|
||||
)
|
||||
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 },
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Parser } from "htmlparser2"
|
||||
import { MAX_MARKDOWN_BYTES } from "./html-markdown-limit.js"
|
||||
|
||||
export { MAX_MARKDOWN_BYTES } from "./html-markdown-limit.js"
|
||||
|
||||
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
|
||||
const blocks = new Set([
|
||||
@@ -47,7 +50,6 @@ type Frame = {
|
||||
|
||||
type Chunk = string | { raw: string }
|
||||
|
||||
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
|
||||
const CONTENT_BYTES = MAX_MARKDOWN_BYTES - 64 * 1024
|
||||
|
||||
export function convertHTMLToMarkdown(html: string) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Parser } from "htmlparser2"
|
||||
import { convertHTMLToMarkdown } from "../html-markdown.js"
|
||||
|
||||
export { convertHTMLToMarkdown }
|
||||
|
||||
export function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipDepth = 0
|
||||
const parser = new Parser({
|
||||
onopentag(name) {
|
||||
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
|
||||
},
|
||||
ontext(input) {
|
||||
if (skipDepth === 0) text += input
|
||||
},
|
||||
onclosetag() {
|
||||
if (skipDepth > 0) skipDepth--
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return text.trim()
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { convertHTMLToMarkdown, MAX_MARKDOWN_BYTES } from "../html-markdown.js"
|
||||
import { MAX_MARKDOWN_BYTES } from "../html-markdown-limit.js"
|
||||
import { collectBoundedResponseBody } from "../http-body.js"
|
||||
|
||||
export const name = "webfetch"
|
||||
@@ -103,11 +102,12 @@ const isTextualMime = (mime: string) =>
|
||||
mime.endsWith("+xml") ||
|
||||
mime === "application/javascript" ||
|
||||
mime === "application/x-javascript"
|
||||
const convert = (content: string, contentType: string, format: Format) => {
|
||||
const convert = async (content: string, contentType: string, format: Format) => {
|
||||
if (!contentType.includes("text/html")) return content
|
||||
if (format === "html") return content
|
||||
const { convertHTMLToMarkdown, extractTextFromHTML } = await import("./webfetch-convert.js")
|
||||
if (format === "markdown") return convertHTMLToMarkdown(content)
|
||||
if (format === "text") return extractTextFromHTML(content)
|
||||
return content
|
||||
return extractTextFromHTML(content)
|
||||
}
|
||||
|
||||
export const Plugin = {
|
||||
@@ -159,7 +159,7 @@ export const Plugin = {
|
||||
}),
|
||||
)
|
||||
const content = new TextDecoder().decode(body)
|
||||
const output = yield* Effect.try({
|
||||
const output = yield* Effect.tryPromise({
|
||||
try: () => convert(content, contentType, input.format),
|
||||
catch: (error) => error,
|
||||
})
|
||||
@@ -176,24 +176,3 @@ export const Plugin = {
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
}
|
||||
|
||||
export function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipDepth = 0
|
||||
const parser = new Parser({
|
||||
onopentag(name) {
|
||||
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
|
||||
},
|
||||
ontext(input) {
|
||||
if (skipDepth === 0) text += input
|
||||
},
|
||||
onclosetag() {
|
||||
if (skipDepth > 0) skipDepth--
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export { convertHTMLToMarkdown }
|
||||
|
||||
@@ -10,6 +10,15 @@ export function base64Decode(value: string) {
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
export async function hash(content: string, algorithm = "SHA-256"): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest(algorithm, data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
return hashHex
|
||||
}
|
||||
|
||||
export function checksum(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
let hash = 0x811c9dc5
|
||||
|
||||
@@ -879,6 +879,88 @@ 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,44 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../../..")
|
||||
|
||||
test("loads the MCP SDK only when connecting or authorizing", async () => {
|
||||
const temporary = await mkdtemp(path.join(import.meta.dir, ".mcp-import-boundary-"))
|
||||
const metafile = path.join(temporary, "meta.json")
|
||||
try {
|
||||
const result = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"build",
|
||||
"packages/core/src/mcp/index.ts",
|
||||
"--target=node",
|
||||
"--format=esm",
|
||||
"--packages=bundle",
|
||||
"--splitting",
|
||||
`--metafile=${metafile}`,
|
||||
`--outdir=${path.join(temporary, "out")}`,
|
||||
],
|
||||
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
result.exited,
|
||||
new Response(result.stdout).text(),
|
||||
new Response(result.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
|
||||
const metadata = await Bun.file(metafile).json()
|
||||
const imports = metadata.inputs["packages/core/src/mcp/index.ts"].imports
|
||||
const lazy = imports.filter(
|
||||
(item: { original?: string }) => item.original === "./client.js" || item.original === "./oauth.js",
|
||||
)
|
||||
expect(new Set(lazy.map((item: { original: string }) => item.original))).toEqual(
|
||||
new Set(["./client.js", "./oauth.js"]),
|
||||
)
|
||||
expect(lazy.every((item: { kind: string }) => item.kind === "dynamic-import")).toBe(true)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } 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,48 +36,6 @@ 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"))
|
||||
|
||||
@@ -629,7 +629,7 @@ describe("Session.create", () => {
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(yield* store.get(created.id)).toBeUndefined()
|
||||
yield* Effect.forEach(serialized.slice(0, 2), (event) => bus.replay(event), { discard: true })
|
||||
expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
||||
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([])
|
||||
|
||||
yield* Effect.forEach(serialized.slice(2), (event) => bus.replay(event), { discard: true })
|
||||
expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
|
||||
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
|
||||
expect(yield* store.context(created.id)).toMatchObject([
|
||||
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
||||
|
||||
@@ -767,7 +767,7 @@ describe("Session.prompt", () => {
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.forEach(
|
||||
yield* bus.replayAll(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
@@ -776,8 +776,6 @@ describe("Session.prompt", () => {
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
(event) => bus.replay(event),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({
|
||||
|
||||
@@ -635,7 +635,7 @@ const replaySessionProjection = (id: Session.ID) =>
|
||||
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* Effect.forEach(
|
||||
yield* bus.replayAll(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
@@ -644,8 +644,6 @@ const replaySessionProjection = (id: Session.ID) =>
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
(event) => bus.replay(event),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1345,7 +1343,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.all()
|
||||
yield* bus.remove(forked.id)
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
|
||||
yield* Effect.forEach(
|
||||
yield* bus.replayAll(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
@@ -1354,8 +1352,6 @@ describe("SessionRunnerLLM", () => {
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
(event) => bus.replay(event),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(
|
||||
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, forked.id)).get(),
|
||||
|
||||
@@ -143,16 +143,15 @@ describe("ToolOutput", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses file modification time when IDs wrap", () =>
|
||||
it.live("removes expired managed files", () =>
|
||||
withStore((output, fs, root) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(root, ToolOutput.DIRECTORY)
|
||||
const old = path.join(directory, Identifier.create("tool", "ascending", 2 ** 36 - 1))
|
||||
const recent = path.join(directory, Identifier.create("tool", "ascending", 2 ** 36 + 1))
|
||||
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"))
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebFetchTool } from "@opencode-ai/core/tool/plugin/webfetch"
|
||||
import { convertHTMLToMarkdown, extractTextFromHTML } from "@opencode-ai/core/tool/plugin/webfetch-convert"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -70,52 +71,50 @@ describe("WebFetchTool helpers", () => {
|
||||
test("ports HTML text and markdown conversions without active content", () => {
|
||||
const html =
|
||||
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
|
||||
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
|
||||
expect(extractTextFromHTML(html)).toBe("Helloworld wide today")
|
||||
expect(convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
|
||||
})
|
||||
|
||||
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
|
||||
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves inline and preformatted code verbatim with safe fences", () => {
|
||||
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n& stays decoded</code></pre>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`Use \`\`say(\`hello\`)\`\` now.\n\n~~~ts\nconst fence = \`\`\`\n& stays decoded\n~~~`,
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps nested ordered and unordered lists structurally readable", () => {
|
||||
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`,
|
||||
)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`)
|
||||
})
|
||||
|
||||
test("renders blockquotes and tables as readable Markdown", () => {
|
||||
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("decodes entities and normalizes prose whitespace without joining words", () => {
|
||||
const html = `<p>alpha\n <span>& beta</span> <unknown>café</unknown> gamma 😀</p><p>delta</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
|
||||
})
|
||||
|
||||
test("omits active and fallback content while retaining surrounding prose", () => {
|
||||
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
|
||||
expect(convertHTMLToMarkdown(html)).toBe("before after")
|
||||
})
|
||||
|
||||
test("is deterministic and bounded for malformed maximum-size input", () => {
|
||||
const html = `<main><p>${"visible & text ".repeat(250_000)}</main></p></unknown>`
|
||||
const first = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
|
||||
const first = convertHTMLToMarkdown(html)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(first)
|
||||
expect(first.startsWith("visible & text visible & text")).toBe(true)
|
||||
expect(first.length).toBeLessThanOrEqual(html.length)
|
||||
})
|
||||
@@ -124,64 +123,62 @@ describe("WebFetchTool helpers", () => {
|
||||
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
||||
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
|
||||
expect(convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
||||
expect(convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
||||
expect(() => convertHTMLToMarkdown(code)).not.toThrow()
|
||||
expect(
|
||||
WebFetchTool.convertHTMLToMarkdown(
|
||||
"<div>".repeat(20_000) + "safe<script><b>bad</b>&</script><p>tail &</p>",
|
||||
),
|
||||
convertHTMLToMarkdown("<div>".repeat(20_000) + "safe<script><b>bad</b>&</script><p>tail &</p>"),
|
||||
).toBe("safe tail &")
|
||||
})
|
||||
|
||||
test("escapes prose that would otherwise become Markdown structure", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
|
||||
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves code whitespace and quotes every line of multiline blocks", () => {
|
||||
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps nested blockquotes inside their outer quote", () => {
|
||||
const html = `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>end</p></blockquote>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
|
||||
})
|
||||
|
||||
test("keeps visible whitespace around inline emphasis", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
|
||||
expect(convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
|
||||
expect(convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
|
||||
})
|
||||
|
||||
test("captures formatting elements inside preformatted content as code only", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
|
||||
expect(convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
|
||||
})
|
||||
|
||||
test("normalizes multiline table cells without changing their columns", () => {
|
||||
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
|
||||
})
|
||||
|
||||
test("flattens nested tables without corrupting the outer table", () => {
|
||||
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves loose text around malformed table rows", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
|
||||
`before after\n\n| cell |\n| --- |`,
|
||||
)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
|
||||
expect(convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
|
||||
})
|
||||
|
||||
test("escapes tilde fences and removes empty emphasis markers", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
|
||||
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
|
||||
)
|
||||
})
|
||||
@@ -190,10 +187,10 @@ describe("WebFetchTool helpers", () => {
|
||||
const small = "<a".repeat(250_000)
|
||||
const large = "<a".repeat(1_000_000)
|
||||
const start = Bun.nanoseconds()
|
||||
WebFetchTool.convertHTMLToMarkdown(small)
|
||||
convertHTMLToMarkdown(small)
|
||||
const smallDuration = Bun.nanoseconds() - start
|
||||
const next = Bun.nanoseconds()
|
||||
WebFetchTool.convertHTMLToMarkdown(large)
|
||||
convertHTMLToMarkdown(large)
|
||||
const largeDuration = Bun.nanoseconds() - next
|
||||
expect(largeDuration).toBeLessThan(smallDuration * 10)
|
||||
})
|
||||
@@ -201,73 +198,69 @@ describe("WebFetchTool helpers", () => {
|
||||
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
|
||||
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
|
||||
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
|
||||
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
|
||||
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
|
||||
const proseOutput = convertHTMLToMarkdown(prose)
|
||||
const codeOutput = convertHTMLToMarkdown(code)
|
||||
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(codeOutput.startsWith("~~~\n")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not confuse source NUL text with buffered code", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
|
||||
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves multiline inline code verbatim", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe(
|
||||
"` first\n\n\nsecond `",
|
||||
)
|
||||
expect(convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe("` first\n\n\nsecond `")
|
||||
})
|
||||
|
||||
test("prefixes inline code at the start of a blockquote line", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
|
||||
expect(convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
|
||||
})
|
||||
|
||||
test("keeps links nested in inline code associated with their text", () => {
|
||||
const html = `<dl><dt><code>socket = new <a href="#constructor">WebSocket</a>(url)</code><dd>Creates one.</dl>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`,
|
||||
)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
|
||||
expect(convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
|
||||
expect(
|
||||
WebFetchTool.convertHTMLToMarkdown(
|
||||
convertHTMLToMarkdown(
|
||||
`<dl><dt><code><var>socket</var> = new <code><a href="#constructor">WebSocket</a></code>(<var>url</var>)</code><dd>Creates one.</dl>`,
|
||||
),
|
||||
).toBe(`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
|
||||
`\`a\`[\`b\`](\/x)[\`c\`](\/y)\`de\``,
|
||||
)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(
|
||||
`\`a\`[](\/x)\n\n\`bcd\``,
|
||||
)
|
||||
expect(convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
|
||||
expect(convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(`\`a\`[](\/x)\n\n\`bcd\``)
|
||||
})
|
||||
|
||||
test("indents nested list continuations and preserves ordered numbering", () => {
|
||||
const html = `<ol start="0"><li value="4"><p>first</p><p>continued</p><ul><li><p>nested</p><p>continued nested</p></li></ul></li><li>next</li></ol>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`4. first\n\n continued\n\n - nested\n\n continued nested\n\n5. next`,
|
||||
)
|
||||
})
|
||||
|
||||
test("renders block content outside link syntax", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
|
||||
`[before](/docs)\n\nblock\n\n[after](/docs)`,
|
||||
)
|
||||
})
|
||||
|
||||
test("recovers nested anchors without unmatched Markdown syntax", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
|
||||
expect(convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
|
||||
})
|
||||
|
||||
test("keeps emphasis whitespace through neutral wrappers", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
|
||||
expect(convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
|
||||
})
|
||||
|
||||
test("flattens preformatted content inside table cells", () => {
|
||||
const html = `<table><tr><td><pre>a|b\nnext</pre></td><td><code>x|y</code></td></tr></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
|
||||
})
|
||||
|
||||
test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
|
||||
@@ -279,7 +272,7 @@ describe("WebFetchTool helpers", () => {
|
||||
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
|
||||
] as const
|
||||
for (const [html, pattern] of cases) {
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
const output = convertHTMLToMarkdown(html)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(output).not.toContain("�")
|
||||
expect(output).toMatch(pattern)
|
||||
@@ -288,11 +281,9 @@ describe("WebFetchTool helpers", () => {
|
||||
|
||||
test("keeps near-boundary block constructs syntactically complete", () => {
|
||||
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
const table = WebFetchTool.convertHTMLToMarkdown(
|
||||
`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`,
|
||||
)
|
||||
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
|
||||
const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
|
||||
const table = convertHTMLToMarkdown(`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`)
|
||||
const list = convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
|
||||
const code = convertHTMLToMarkdown(`<pre>${payload}</pre>`)
|
||||
for (const output of [table, list, code]) {
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect(output).not.toContain("�")
|
||||
@@ -305,7 +296,7 @@ describe("WebFetchTool helpers", () => {
|
||||
|
||||
test("keeps quoted code within budget with a safe closed fence", () => {
|
||||
const html = `<blockquote><pre>${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</pre></blockquote>`
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
const output = convertHTMLToMarkdown(html)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
const lines = output.split("\n")
|
||||
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
|
||||
@@ -314,14 +305,14 @@ describe("WebFetchTool helpers", () => {
|
||||
|
||||
test("separates reconstructed tables from adjacent inline and quoted content", () => {
|
||||
const html = `intro<table><tr><td>x</td></tr></table>outro<blockquote>quote<table><tr><td>cell</td></tr></table></blockquote><ul><li>item<table><tr><td>cell</td></tr></table></li></ul>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`intro\n\n| x |\n| --- |\n\noutro\n\n> quote\n\n> | cell |\n> | --- |\n\n- item\n\n| cell |\n| --- |`,
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps multiline quoted code closed at the content budget", () => {
|
||||
const html = `<blockquote><pre>${"x\n".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 2)}</pre></blockquote><p>tail</p>`
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
const output = convertHTMLToMarkdown(html)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
|
||||
expect(output.includes("\uFFFD")).toBe(false)
|
||||
@@ -330,54 +321,52 @@ describe("WebFetchTool helpers", () => {
|
||||
|
||||
test("keeps active content suppressed when depth fallback begins", () => {
|
||||
const html = `<object>${"<div>".repeat(10_001)}LEAK${"</div>".repeat(10_001)}</object><p>visible</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible")
|
||||
expect(convertHTMLToMarkdown(html)).toBe("visible")
|
||||
})
|
||||
|
||||
test("keeps visible text after depth fallback begins inside preformatted content", () => {
|
||||
const html = `<pre>${"<i>".repeat(10_001)}visible${"</i>".repeat(10_001)}</pre><p>after</p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible after")
|
||||
expect(convertHTMLToMarkdown(html)).toBe("visible after")
|
||||
})
|
||||
|
||||
test("resumes links around every block structure", () => {
|
||||
const html = `<a href="/x">before<blockquote><p>quote</p></blockquote><ul><li>item</li></ul><pre>code</pre><table><tr><td>cell</td></tr></table>after</a>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`[before](/x)\n\n> quote\n\n- item\n\n\`\`\`\ncode\n\`\`\`\n\n| cell |\n| --- |\n\n[after](/x)`,
|
||||
)
|
||||
})
|
||||
|
||||
test("indents child lists from the actual parent marker width", () => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
|
||||
expect(convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
|
||||
`100. outer\n\n - inner`,
|
||||
)
|
||||
})
|
||||
|
||||
test("renders captions and definition lists with readable boundaries", () => {
|
||||
const html = `<table><caption>Cache modes</caption><tr><th>Name</th><th>Meaning</th></tr><tr><td>A</td><td>Local</td></tr></table><dl><dt>Cache</dt><dd>A local store</dd><dt>Origin</dt><dd>The remote source</dd></dl>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
expect(convertHTMLToMarkdown(html)).toBe(
|
||||
`Cache modes\n\n| Name | Meaning |\n| --- | --- |\n| A | Local |\n\n**Cache**\n: A local store\n\n**Origin**\n: The remote source`,
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to row-oriented text for table spans", () => {
|
||||
const html = `<table><tr><th colspan="2">Group</th></tr><tr><td>A</td><td rowspan="2">Shared</td></tr><tr><td>B</td></tr></table>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
|
||||
})
|
||||
|
||||
test("suppresses head and hidden subtrees while retaining visible body content", () => {
|
||||
const html = `<head><title>noise</title></head><body><p>visible</p><div hidden>hidden</div><div aria-hidden="true">aria</div><div aria-hidden="false">shown</div></body>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
|
||||
})
|
||||
|
||||
test("preserves pre breaks and normalizes multiline link titles", () => {
|
||||
const html = `<pre>first<br>second</pre><p><a href="/x" title="line one\n line two">link</a></p>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`,
|
||||
)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`)
|
||||
})
|
||||
|
||||
test("renders closed and open details according to visibility", () => {
|
||||
const html = `<details><summary>Closed</summary><p>secret</p></details><details open><summary>Open</summary><p>visible</p></details>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
|
||||
expect(convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -399,6 +388,11 @@ describe("WebFetchTool registration", () => {
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
])
|
||||
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
|
||||
|
||||
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -482,6 +476,10 @@ describe("WebFetchTool registration", () => {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "Helloworld" }],
|
||||
})
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "html" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "<h1>Hello</h1><p>world</p><script>bad()</script>" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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,7 +18,6 @@ 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"
|
||||
@@ -127,7 +126,6 @@ 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) }
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"@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",
|
||||
|
||||
@@ -71,8 +71,8 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
|
||||
function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) {
|
||||
const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) })
|
||||
@@ -430,7 +430,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected()))
|
||||
const color = tint(base, glowHue(), numberGlow.value().level)
|
||||
const color = status().attention
|
||||
? theme.text.feedback.warning.default
|
||||
: status().unread === "error"
|
||||
? theme.text.feedback.error.default
|
||||
: tint(base, accent(), Number(complete()))
|
||||
const runningColor = runs() ? activeNumber() : color
|
||||
return sweepLevel() === 0
|
||||
? tint(runningColor, theme.text.default, numberIgnition.value().level)
|
||||
@@ -441,13 +445,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
return selected() ? theme.text.default : theme.text.subdued
|
||||
}
|
||||
const complete = () => status().complete
|
||||
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent.
|
||||
let lastGlowHue: RGBA | undefined
|
||||
const glowHue = () => {
|
||||
if (status().attention) return (lastGlowHue = theme.text.feedback.warning.default)
|
||||
if (status().unread === "error") return (lastGlowHue = theme.text.feedback.error.default)
|
||||
if (status().unread !== undefined) return (lastGlowHue = accent())
|
||||
return lastGlowHue ?? accent()
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7))
|
||||
@@ -461,21 +462,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
|
||||
: detailColor()
|
||||
const glows = () => status().glows
|
||||
// Text tints ride an eased level so they diffuse away with the background glow instead of snapping.
|
||||
const titleGlow = createAnimatable(
|
||||
{ level: 0 },
|
||||
{ enabled: animations, transition: tween({ duration: 0.4 }) },
|
||||
)
|
||||
createEffect(() => titleGlow.animate({ level: glows() ? 1 : 0 }))
|
||||
const numberGlow = createAnimatable(
|
||||
{ level: 0 },
|
||||
{ enabled: animations, transition: tween({ duration: 0.4 }) },
|
||||
)
|
||||
createEffect(() =>
|
||||
numberGlow.animate({
|
||||
level: status().attention || status().unread === "error" || complete() ? 1 : 0,
|
||||
}),
|
||||
)
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
const tab = previous()
|
||||
@@ -486,22 +472,17 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const indicatorWidth = 10
|
||||
let lastPreviousGlowHue: RGBA | undefined
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return (lastPreviousGlowHue = theme.text.feedback.warning.default)
|
||||
if (previousStatus().unread === "error")
|
||||
return (lastPreviousGlowHue = theme.text.feedback.error.default)
|
||||
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent())
|
||||
return lastPreviousGlowHue ?? accent()
|
||||
if (previousStatus().attention) return theme.text.feedback.warning.default
|
||||
if (previousStatus().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const titleColor = (index: number) => {
|
||||
const level = titleGlow.value().level
|
||||
const color =
|
||||
level === 0
|
||||
? foreground()
|
||||
: glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width(), level)
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
return titleFades()
|
||||
? fadeTitleColor(
|
||||
color,
|
||||
@@ -647,7 +628,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
<Show when={titleGlow.value().level > 0 || titleFades()} fallback={visibleTitle()}>
|
||||
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
|
||||
@@ -47,9 +47,6 @@ const GLOW_IGNITION_DURATION = 600
|
||||
const GLOW_IGNITION_PEAK = 1.5
|
||||
const GLOW_IGNITION_ATTACK = 0.3
|
||||
const GLOW_FADE_OUT = 200
|
||||
const GLOW_RELEASE_DURATION = 900
|
||||
const GLOW_RELEASE_ATTACK = 0.12
|
||||
const GLOW_RELEASE_PEAK = 1.25
|
||||
const GLOW_TAIL = 12
|
||||
const GLOW_OPACITY = 0.16
|
||||
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
||||
@@ -78,8 +75,6 @@ export const unreadGlowIntensity = (index: number, width: number, maximumTail =
|
||||
const tail = Math.min(maximumTail, Math.max(1, width - 2))
|
||||
return glowIntensityAt(index, tail)
|
||||
}
|
||||
/** How far a resolving glow has diffused: the resting tail spreads across the full width as it thins away. */
|
||||
const glowReleaseSpread = (progress: number, tail: number, width: number) => tail + smootherstep(progress) * width
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
background: RGBA,
|
||||
@@ -153,10 +148,6 @@ class Envelope {
|
||||
return this.clock !== undefined
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return this.clock === undefined ? undefined : this.clock / this.duration
|
||||
}
|
||||
|
||||
level() {
|
||||
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
|
||||
}
|
||||
@@ -187,19 +178,7 @@ class PulseState {
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
// A resolving glow's send-off: a gentle swell that decays while the spatial profile diffuses outward.
|
||||
private release = new Envelope(GLOW_RELEASE_DURATION, (progress) =>
|
||||
attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0),
|
||||
)
|
||||
private envelopes = [
|
||||
this.runAttack,
|
||||
this.runFade,
|
||||
this.completionPulse,
|
||||
this.edgeFlash,
|
||||
this.ignition,
|
||||
this.glowOff,
|
||||
this.release,
|
||||
]
|
||||
private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
|
||||
constructor(options: PulseStateOptions) {
|
||||
this.enabled = options.enabled
|
||||
@@ -232,14 +211,6 @@ class PulseState {
|
||||
return this.ignition.active ? this.ignition.level() : 1
|
||||
}
|
||||
|
||||
get releaseLevel() {
|
||||
return this.release.level()
|
||||
}
|
||||
|
||||
get releaseProgress() {
|
||||
return this.release.progress
|
||||
}
|
||||
|
||||
setEnabled(value: boolean) {
|
||||
if (value === this.enabled) return false
|
||||
this.enabled = value
|
||||
@@ -295,16 +266,11 @@ class PulseState {
|
||||
|
||||
setGlow(value: boolean) {
|
||||
if (value === this.glow) return false
|
||||
if (this.enabled && !value) {
|
||||
// Resolving the glow sends it off: residual glow drains while the release diffuses outward.
|
||||
this.glowOff.start(this.glowLevel)
|
||||
this.release.restart(Math.max(1, this.glowLevel))
|
||||
}
|
||||
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
||||
this.glow = value
|
||||
this.ignition.stop()
|
||||
if (this.enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.release.stop()
|
||||
this.ignition.start()
|
||||
}
|
||||
return true
|
||||
@@ -557,20 +523,16 @@ class TabPulseRenderable extends Renderable {
|
||||
const completion = this.inner.completion
|
||||
const flash = this.inner.flash
|
||||
const glowLevel = this.inner.glowLevel
|
||||
const releaseLevel = this.inner.releaseLevel
|
||||
const outerRunning = this.outer.running
|
||||
const outerCompletion = this.outer.completion
|
||||
const outerFlash = this.outer.flash
|
||||
const outerGlowLevel = this.outer.glowLevel
|
||||
const outerReleaseLevel = this.outer.releaseLevel
|
||||
if (
|
||||
glowLevel === 0 &&
|
||||
releaseLevel === 0 &&
|
||||
running === 0 &&
|
||||
completion === 0 &&
|
||||
flash === 0 &&
|
||||
outerGlowLevel === 0 &&
|
||||
outerReleaseLevel === 0 &&
|
||||
outerRunning === 0 &&
|
||||
outerCompletion === 0 &&
|
||||
outerFlash === 0
|
||||
@@ -589,8 +551,6 @@ class TabPulseRenderable extends Renderable {
|
||||
)
|
||||
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
|
||||
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
|
||||
const releaseSpread = glowReleaseSpread(this.inner.releaseProgress ?? 0, glowTail, this.width)
|
||||
const outerReleaseSpread = glowReleaseSpread(this.outer.releaseProgress ?? 0, outerGlowTail, this.width)
|
||||
const flashTail = this._flashTail === undefined ? undefined : Math.min(this._flashTail, Math.max(1, this.width - 2))
|
||||
const outerFlashTail =
|
||||
this._outerFlashTail === undefined ? undefined : Math.min(this._outerFlashTail, Math.max(1, this.width - 2))
|
||||
@@ -621,10 +581,7 @@ class TabPulseRenderable extends Renderable {
|
||||
this._color,
|
||||
this._flashColor,
|
||||
this._completionColor,
|
||||
Math.max(
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
releaseLevel === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseLevel,
|
||||
),
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
sweep,
|
||||
flashTail === undefined ? flash : flash * tabFlashIntensity(index, flashTail),
|
||||
completion,
|
||||
@@ -640,10 +597,7 @@ class TabPulseRenderable extends Renderable {
|
||||
this._outerColor,
|
||||
this._outerFlashColor,
|
||||
this._outerCompletionColor,
|
||||
Math.max(
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
outerReleaseLevel === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseLevel,
|
||||
),
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
outerSweep,
|
||||
outerFlashTail === undefined ? outerFlash : outerFlash * tabFlashIntensity(index, outerFlashTail),
|
||||
outerCompletion,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -38,11 +37,6 @@ 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.
|
||||
@@ -58,10 +52,7 @@ 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", {
|
||||
@@ -76,20 +67,11 @@ 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 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)
|
||||
})
|
||||
const scrollPositions = new Map<string, number>()
|
||||
|
||||
createEffect(() => {
|
||||
if (config.experimental?.tab_scroll === true) return
|
||||
scrollAnchors.clear()
|
||||
scrollPositions.clear()
|
||||
})
|
||||
|
||||
function state() {
|
||||
@@ -143,7 +125,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
}
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled() || !focused()) return
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
|
||||
if (state().unread[session] === unread) return
|
||||
@@ -163,21 +145,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
sessionID,
|
||||
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
if (tabs === state().tabs) return
|
||||
if (tabs === state().tabs && !state().unread[sessionID]) 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]
|
||||
})
|
||||
})
|
||||
@@ -265,7 +238,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
scrollAnchors.delete(target)
|
||||
scrollPositions.delete(target)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = navigate && current() === target
|
||||
if (closed.tabs === state().tabs && !selected) return
|
||||
@@ -297,20 +270,18 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
},
|
||||
current,
|
||||
status,
|
||||
scrollAnchor(sessionID: string) {
|
||||
scrollPosition(sessionID: string) {
|
||||
const target = root(sessionID)
|
||||
if (!state().tabs.some((tab) => tab.sessionID === target)) return
|
||||
return scrollAnchors.get(target)
|
||||
return scrollPositions.get(target)
|
||||
},
|
||||
setScrollAnchor(sessionID: string, anchor: ScrollAnchor | undefined) {
|
||||
setScrollPosition(sessionID: string, position: number | undefined) {
|
||||
const target = root(sessionID)
|
||||
if (anchor === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollAnchors.delete(target)
|
||||
if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollPositions.delete(target)
|
||||
return
|
||||
}
|
||||
const current = scrollAnchors.get(target)
|
||||
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
|
||||
scrollAnchors.set(target, anchor)
|
||||
scrollPositions.set(target, position)
|
||||
},
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
|
||||
@@ -149,7 +149,6 @@ export function Session() {
|
||||
await writeFile(file, content)
|
||||
}
|
||||
const route = useRouteData("session")
|
||||
const sessionID = route.sessionID
|
||||
const { navigate } = useRoute()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
@@ -267,21 +266,13 @@ export function Session() {
|
||||
})
|
||||
})
|
||||
const editor = useEditorContext()
|
||||
const [rowsSynced, setRowsSynced] = createSignal(false)
|
||||
const rows = createSessionRows(
|
||||
() => route.sessionID,
|
||||
(id) => {
|
||||
if (id === sessionID) setRowsSynced(true)
|
||||
},
|
||||
)
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
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)
|
||||
@@ -327,6 +318,7 @@ 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
|
||||
@@ -341,18 +333,13 @@ 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
|
||||
saveScrollAnchor()
|
||||
sessionTabs.setScrollPosition(
|
||||
route.sessionID,
|
||||
config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined,
|
||||
)
|
||||
})
|
||||
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
@@ -377,10 +364,8 @@ 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 visibleEnd = createMemo(() => Math.max(hidden(), Math.min(visibleRowsEnd() ?? rows.length, rows.length)))
|
||||
const visibleRows = createMemo(() => rows.slice(hidden(), visibleEnd()))
|
||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||
let revealingOlderRows = false
|
||||
const revealOlderRows = (scrollBy = 0) => {
|
||||
const current = hidden()
|
||||
@@ -402,102 +387,36 @@ 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 && visibleEnd() === rows.length) return continuation()
|
||||
if (hidden() === 0) 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
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = setTimeout(() => {
|
||||
awayTimer = undefined
|
||||
setTimeout(() => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
const away = isAwayFromBottom()
|
||||
setAwayFromBottom(away)
|
||||
saveScrollAnchor()
|
||||
if (!away) sessionTabs.setScrollPosition(route.sessionID, 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) {
|
||||
function restoreScrollPosition(sessionID: string) {
|
||||
const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined
|
||||
if (position === undefined) {
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
setAwayFromBottom(false)
|
||||
return
|
||||
}
|
||||
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()
|
||||
ensureAllRows(() => {
|
||||
scroll.scrollTo(position)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -608,31 +527,20 @@ export function Session() {
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = undefined
|
||||
setAwayFromBottom(false)
|
||||
sessionTabs.setScrollAnchor(route.sessionID, undefined)
|
||||
setHiddenRows(undefined)
|
||||
setVisibleRowsEnd(undefined)
|
||||
sessionTabs.setScrollPosition(route.sessionID, 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)) {
|
||||
dialog.clear()
|
||||
return
|
||||
if (delta >= 0 || !revealOlderRows(delta)) {
|
||||
scroll.scrollBy(delta)
|
||||
updateAwayFromBottom()
|
||||
}
|
||||
if (delta > 0 && revealNewerRows(delta)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
scroll.scrollBy(delta)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
@@ -1176,7 +1084,6 @@ export function Session() {
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
onMouseScroll={(event) => {
|
||||
if (event.scroll?.direction === "up" && revealOlderRows()) return
|
||||
if (event.scroll?.direction === "down" && revealNewerRows()) return
|
||||
updateAwayFromBottom()
|
||||
}}
|
||||
>
|
||||
@@ -1203,12 +1110,7 @@ export function Session() {
|
||||
</box>
|
||||
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
|
||||
<text
|
||||
fg={latestHovered() ? theme.text.default : theme.text.subdued}
|
||||
onMouseOver={() => setLatestHovered(true)}
|
||||
onMouseOut={() => setLatestHovered(false)}
|
||||
onMouseUp={toBottom}
|
||||
>
|
||||
<text fg={theme.text.subdued} onMouseUp={toBottom}>
|
||||
Latest ↓
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -35,7 +35,7 @@ export type SessionRow =
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const config = useConfig()
|
||||
@@ -95,7 +95,6 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
onSynced?.(id)
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
@@ -142,9 +142,6 @@ 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()
|
||||
@@ -153,14 +150,6 @@ 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))
|
||||
@@ -213,46 +202,6 @@ test("stores session tabs for the current working directory by default", async (
|
||||
}
|
||||
})
|
||||
|
||||
test("only the foreground TUI mutates unread state", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
try {
|
||||
foreground = await renderSessionTabs("first", { state: temporary.path })
|
||||
background = await renderSessionTabs("second", { state: temporary.path })
|
||||
foreground.focus()
|
||||
background.blur()
|
||||
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2)
|
||||
|
||||
const firstDone = executionSucceeded("first")
|
||||
foreground.emit(firstDone)
|
||||
background.emit(firstDone)
|
||||
await Promise.all([foreground.flush(), background.flush()])
|
||||
expect(foreground.tabs.status("first").unread).toBeUndefined()
|
||||
expect(background.tabs.status("first").unread).toBeUndefined()
|
||||
|
||||
const secondDone = executionSucceeded("second")
|
||||
foreground.emit(secondDone)
|
||||
background.emit(secondDone)
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === "activity" &&
|
||||
background?.tabs.status("second").unread === "activity",
|
||||
)
|
||||
|
||||
foreground.tabs.select("second")
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === undefined &&
|
||||
background?.tabs.status("second").unread === undefined,
|
||||
)
|
||||
} finally {
|
||||
if (foreground) await foreground.destroy()
|
||||
if (background) await background.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const state = temporary.path
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
"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",
|
||||
@@ -95,6 +97,8 @@
|
||||
"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:",
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
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,6 +1,7 @@
|
||||
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"
|
||||
@@ -110,7 +111,6 @@ 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 {
|
||||
|
||||
@@ -52,7 +52,7 @@ export default defineWorkersConfig({
|
||||
// lookup surface but back it with a static shim.
|
||||
{ find: /^mime-types$/, replacement: new URL("./test/shims/mime-types.mjs", import.meta.url).pathname },
|
||||
// Plugin installs never happen in the workerd profile (plugin discovery
|
||||
// is precompiled-only), so mock package installation when it is loaded.
|
||||
// is precompiled-only), so mock the package installation toolchain.
|
||||
{ find: /^@npmcli\/arborist(\/.*)?$/, replacement: mockProxy },
|
||||
{ find: /^pacote(\/.*)?$/, replacement: mockProxy },
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
github: {
|
||||
owner: "anomalyco",
|
||||
repo: "opencode",
|
||||
branch: "v2",
|
||||
branch: "dev",
|
||||
dir: "packages/www",
|
||||
},
|
||||
theme: {
|
||||
|
||||
@@ -75,16 +75,10 @@ describe("translate app", () => {
|
||||
"packages/ui/src/i18n/dv.ts",
|
||||
"packages/desktop/src/renderer/i18n/dv.ts",
|
||||
])
|
||||
expect(targetFiles("he")).toEqual([
|
||||
"packages/app/src/i18n/he.ts",
|
||||
"packages/ui/src/i18n/he.ts",
|
||||
"packages/desktop/src/renderer/i18n/he.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("maps product locale codes to their glossaries", () => {
|
||||
expect(glossaryFile("fr")).toBe(".opencode/glossary/fr.md")
|
||||
expect(glossaryFile("he")).toBe(".opencode/glossary/he.md")
|
||||
expect(glossaryFile("zh")).toBe(".opencode/glossary/zh-cn.md")
|
||||
expect(glossaryFile("zht")).toBe(".opencode/glossary/zh-tw.md")
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ const locales = DESKTOP_NATIVE_LOCALES.filter((locale): locale is Locale => loca
|
||||
|
||||
const languages = {
|
||||
ar: "Arabic",
|
||||
he: "Hebrew",
|
||||
br: "Brazilian Portuguese",
|
||||
bs: "Bosnian",
|
||||
da: "Danish",
|
||||
|
||||
Reference in New Issue
Block a user