Compare commits

...

152 Commits

Author SHA1 Message Date
Kit Langton 1ecf27f389 refactor(opencode): pass prepared request to native runtime 2026-05-20 21:55:45 -04:00
Kit Langton 548446a990 refactor(opencode): extract session LLM request prep 2026-05-20 21:38:32 -04:00
Kit Langton ddd6eb4496 fix(tui): separate question checkmark labels (#28558) 2026-05-20 21:35:59 -04:00
opencode-agent[bot] 12bbe84360 chore: generate 2026-05-21 01:30:35 +00:00
Kit Langton 661df8fcf8 fix(opencode): register account events in EventV2 bridge (#28555) 2026-05-20 21:29:12 -04:00
Kit Langton 16fb6dac8d fix(llm): restore OpenAI reasoning streams (#28552) 2026-05-20 21:02:59 -04:00
opencode-agent[bot] 93131b6e4c chore: generate 2026-05-21 00:55:10 +00:00
Dax 8643c0721e Rename v2 auth service to account (#28260) 2026-05-21 00:53:27 +00:00
opencode-agent[bot] bd41dac88f chore: update nix node_modules hashes 2026-05-21 00:26:08 +00:00
Kit Langton 41f6daf96a Refactor LLM route-first provider API (#28523) 2026-05-20 20:15:52 -04:00
Kit Langton 5381795844 feat(effect-drizzle-sqlite): add vendored sqlite adapter (#28547) 2026-05-20 20:09:07 -04:00
Frank 7b9d7a7b7d sync 2026-05-20 19:45:59 -04:00
Frank 7b0fd9fed2 zen: grok build 0.1 2026-05-20 19:38:37 -04:00
opencode-agent[bot] b4a01cc3cd chore: generate 2026-05-20 22:37:29 +00:00
James Long a6e1aa085f fix(tui): default new sessions always to local project (#28541) 2026-05-20 18:35:37 -04:00
opencode 6a9cbe7de0 sync release versions for v1.15.6 2026-05-20 21:44:08 +00:00
opencode-agent[bot] 0e17c4f299 chore: update nix node_modules hashes 2026-05-20 21:01:29 +00:00
opencode-agent[bot] 7c121d48b9 chore: generate 2026-05-20 20:56:18 +00:00
Aiden Cline f5a8202b41 fix(tui): simplify thinking toggle styling (#28487) 2026-05-20 15:54:29 -05:00
Kit Langton 43c24d8d0f fix(tui): gate Zed context on terminal env (#28517) 2026-05-20 16:52:33 -04:00
opencode-agent[bot] 09603ed52f chore: generate 2026-05-20 20:50:28 +00:00
Kit Langton 38e3b4087b test(server): port event-diagnostics to Effect runner (#28520) 2026-05-20 16:48:41 -04:00
opencode-agent[bot] eb52362e96 chore: generate 2026-05-20 20:45:33 +00:00
Sebastian 2969a513ce upgrade opentui to 0.2.15 (#28510) 2026-05-20 22:44:09 +02:00
opencode-agent[bot] de672fd214 chore: generate 2026-05-20 20:23:54 +00:00
James Long d0779d2aca feat(tui): collapse directories when possible in file tree (#28512) 2026-05-20 16:22:04 -04:00
opencode-agent[bot] 650594e801 chore: update nix node_modules hashes 2026-05-20 20:13:43 +00:00
James Long 58143c4b07 feat(tui): focus first file in file tree (#28513) 2026-05-20 16:04:45 -04:00
Kit Langton 4cbeacbc76 chore: update Drizzle RC (#28506) 2026-05-20 15:55:36 -04:00
opencode-agent[bot] b0ca0419be chore: generate 2026-05-20 19:25:52 +00:00
Shoubhit Dash 4308dd75fb fix(httpapi): expose v2 catalog errors (#28498) 2026-05-21 00:53:35 +05:30
opencode-agent[bot] 7690481fc1 chore: update nix node_modules hashes 2026-05-20 19:23:14 +00:00
Kit Langton ec6d42d41d chore: update Effect beta (#28505) 2026-05-20 15:09:19 -04:00
opencode-agent[bot] 3553754083 chore: generate 2026-05-20 19:09:16 +00:00
Simon Klee ed839846d1 run: replace subagent tabs with on-demand picker (#28508)
Move subagent navigation into the existing palette: a
"View subagents" command entry, a dedicated picker panel, and a
Down-arrow shortcut from the empty composer.
2026-05-20 21:07:35 +02:00
Kit Langton ba803dd89a fix(sdk): unbreak typecheck on dev after v2 error widening (#28503) 2026-05-20 14:29:19 -04:00
opencode-agent[bot] 0e118d1961 chore: generate 2026-05-20 17:57:38 +00:00
Shoubhit Dash 40e73c4910 fix(httpapi): expose v2 request errors (#28495) 2026-05-20 23:23:23 +05:30
opencode-agent[bot] 9559e2425b chore: generate 2026-05-20 17:29:32 +00:00
Frank ac76af8c56 zen: add gemini 3.5 flash 2026-05-20 13:20:54 -04:00
Victor Navarro b67b03e0ae chore: simplify referral codes (#28491) 2026-05-20 19:15:20 +02:00
Sebastian ef82426e28 handle permanent file plugin errors (#27344) 2026-05-20 18:47:30 +02:00
OpeOginni 14e9e5d9d6 fix(enterprise): message nav hovercard and active state (#23964) 2026-05-21 00:06:13 +08:00
Dax 9324ef0d08 fix(cli): default console login url (#28474) 2026-05-20 11:34:56 -04:00
opencode-agent[bot] 8940572098 chore: generate 2026-05-20 15:18:18 +00:00
James Long 17d66ee4fe feat(tui): initial impl of diff viewer (#28476) 2026-05-20 11:16:56 -04:00
opencode-agent[bot] 13006d6d7c chore: generate 2026-05-20 07:10:40 +00:00
Simon Klee 539b118690 run: add shell mode to prompt (#28315)
Press `!` on an empty prompt to enter shell mode and run a command
through session.shell instead of sending a message
2026-05-20 09:09:12 +02:00
opencode-agent[bot] 11f7e5a1b0 chore: generate 2026-05-20 06:41:26 +00:00
Brendan Allan 38b406fb35 app: Initial tabs impl (#28436) 2026-05-20 14:40:06 +08:00
opencode-agent[bot] 4702cddb3e chore: generate 2026-05-20 04:29:32 +00:00
Luke Parker 82c5d45601 Add Windows desktop app menu (#28420) 2026-05-20 14:28:15 +10:00
OpeOginni 66d409d679 fix(opencode): Update directory and path fields of imported session (#27516) 2026-05-19 19:09:23 -05:00
Kit Langton 4ad261d8a7 Skip git setup for processor tool test (#28406) 2026-05-19 18:09:15 -04:00
Kit Langton 59c99dc62d Skip snapshot seed file commits (#28405) 2026-05-19 17:45:55 -04:00
Kit Langton a8f7c5ec93 Run CLI subprocess tests concurrently (#28399) 2026-05-19 17:26:41 -04:00
Kit Langton 34cae2f3cb fix(action): remove orphan symlink breaking GitHub Action staging (#28390) 2026-05-19 20:49:22 +00:00
Kit Langton e94d46af86 fix(config): resolve agent/command names from relative paths (#28359) 2026-05-19 20:48:32 +00:00
Kit Langton c035c35eba fix(config): tolerate invalid OPENCODE_PERMISSION JSON (#28388) 2026-05-19 20:37:50 +00:00
Kit Langton b70b45964a Skip LLM server for prompt tests without LLM calls (#28391) 2026-05-19 16:27:36 -04:00
opencode-agent[bot] e3c8d221ec chore: generate 2026-05-19 20:24:23 +00:00
Kit Langton 80e5fb11c2 refactor(test/cli): migrate harness short-lived path to AppProcess + FileSystem (#28366) 2026-05-19 16:18:19 -04:00
Dustin Deus bc6c4c72ab chore(triage): add 'starptech' to core and inference teams (#28376) 2026-05-19 15:15:04 -05:00
Kit Langton 4db2746e31 Reduce snapshot batch test fixture sizes (#28381) 2026-05-19 16:10:43 -04:00
opencode-agent[bot] 71e90073d2 chore: generate 2026-05-19 19:04:32 +00:00
Frank ea27114eb9 go: update referral invite ui style 2026-05-19 15:01:27 -04:00
Frank 64d67f2134 sync 2026-05-19 14:29:55 -04:00
opencode-agent[bot] a99337fbe1 chore: generate 2026-05-19 18:28:20 +00:00
Victor Navarro b20b569b0e chore(go): referral improvements (#28368) 2026-05-19 20:26:38 +02:00
Kit Langton c449d3dc74 Migrate remaining legacy tools config tests (#28363) 2026-05-19 12:06:07 -04:00
opencode-agent[bot] e4eb98b991 chore: generate 2026-05-19 15:52:51 +00:00
Victor Navarro b32f071502 feat(go): referral support (#28345)
Co-authored-by: Jack <jack@anoma.ly>
2026-05-19 17:51:39 +02:00
Kit Langton 512e34af83 Migrate MCP config tests to instance fixtures (#28338) 2026-05-19 11:45:13 -04:00
Kit Langton 7051796c38 test(cli): tier-A read-only command smoke tests (#28274) 2026-05-19 11:35:48 -04:00
Kit Langton b67f5d741f test(opencode/run): skip Windows-only scrollback replay failure (#28261) 2026-05-19 11:24:23 -04:00
opencode-agent[bot] eec0843ce4 chore: generate 2026-05-19 15:11:40 +00:00
Kit Langton 55baa16fbc test(lib): extract snapshot normalizer utility for cross-OS stability (#28356) 2026-05-19 11:09:49 -04:00
Kit Langton c79a9634d3 fix(tool): tolerate plugin tool defs with missing args (#28357) 2026-05-19 15:07:28 +00:00
Sebastian 8dd6448c90 use keymap state for layer visibility (#26246) 2026-05-19 17:07:26 +02:00
Kit Langton 18b9cec50d test(cli): help-text snapshots for every CLI command (#28267) 2026-05-19 10:58:12 -04:00
Shawn 2932a7a35d fix(app): invalidate provider queries after config update to show custom providers immediately (#28313) 2026-05-19 20:31:36 +08:00
Brendan Allan 922b2e10eb refactor(app): remove Accessor wrapping from timeline row renders (#28334) 2026-05-19 20:22:50 +08:00
opencode-agent[bot] a2ee437a0e chore: generate 2026-05-19 12:21:56 +00:00
Kit Langton 6618e2bce2 feat(native-llm): route Anthropic API-key models through native runtime (#28271) 2026-05-19 08:20:27 -04:00
Kit Langton cb15b3ad84 test(cli): subprocess integration tests for opencode acp (#28265) 2026-05-19 08:18:22 -04:00
Sebastian ee16f08ffa Fix legacy pgup/pgdown TUI keybind aliases (#28275) 2026-05-19 13:52:00 +02:00
Dustin Deus 9790a61f96 chore(docs): remove beta zen go (#28317)
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
2026-05-19 12:08:40 +02:00
Dustin Deus 2339aacbf2 chore(team): add starptech to the team members list (#28320) 2026-05-19 10:49:10 +02:00
opencode-agent[bot] 2d348da504 chore: generate 2026-05-19 06:47:00 +00:00
MYMDO cbd2620c46 feat(i18n): add Ukrainian (uk) locale support (#28061) 2026-05-19 14:45:39 +08:00
opencode-agent[bot] 3bd304796b chore: generate 2026-05-19 06:13:16 +00:00
Shoubhit Dash dac81cdb68 fix(httpapi): preserve v2 openapi errors (#28298) 2026-05-19 11:42:01 +05:30
Shoubhit Dash 2c3bcf3e41 feat(httpapi): add v2 public error schemas (#28297) 2026-05-19 11:26:56 +05:30
Kit Langton d9d43d8519 test: migrate plugin config fixtures 2026-05-18 22:14:01 -04:00
Kit Langton bcc69f0afb test: migrate managed config fixtures 2026-05-18 22:02:59 -04:00
Kit Langton e53563f402 test: migrate remaining simple config fixtures 2026-05-18 21:42:00 -04:00
Kit Langton 7e4b02f1bf test: migrate config legacy tool fixtures 2026-05-18 21:27:43 -04:00
Kit Langton 996928b32a Migrate config .opencode file tests to instance fixtures (#28268) 2026-05-18 21:17:11 -04:00
Kit Langton c032e821bc Migrate config update tests to instance fixtures (#28266) 2026-05-18 20:57:42 -04:00
opencode-agent[bot] 6f160bb48f chore: generate 2026-05-19 00:57:09 +00:00
Kit Langton ebb672ac39 test(cli): subprocess integration tests for opencode serve (#28263) 2026-05-19 00:55:58 +00:00
Kit Langton 9a19e84265 Migrate config agent tests to instance fixtures (#28213) 2026-05-18 23:55:33 +00:00
Kit Langton 338666d13e Migrate config template tests to instance fixtures (#28211) 2026-05-18 23:37:58 +00:00
Kit Langton 7b8a1037a0 refactor(test/lib): generalize run-process harness into cli-process (#28253) 2026-05-18 19:16:10 -04:00
Kit Langton ee5cf45ef9 Migrate simple config tests to instance fixtures (#28210) 2026-05-18 19:13:16 -04:00
opencode-agent[bot] 2e1593dea5 chore: generate 2026-05-18 22:33:32 +00:00
Kit Langton 0f3d168fdd test(cli): subprocess integration test harness + regression suite for opencode run (#28230) 2026-05-18 18:32:20 -04:00
opencode-agent[bot] ce09fc8356 chore: generate 2026-05-18 22:03:00 +00:00
Luke Parker 44a35c5895 test(app): add session timeline smoke coverage (#26619) 2026-05-18 22:01:29 +00:00
Kit Langton 8a321c4536 fix(native-llm): prefer console opencode token (#28237) 2026-05-18 17:23:57 -04:00
opencode ef9e567e5f sync release versions for v1.15.5 2026-05-18 20:57:13 +00:00
Kit Langton b396b71c6f fix(ui): guard reasoning renderer against undefined text (#28222) 2026-05-18 16:04:07 -04:00
Aiden Cline d8efc575fa refactor(session): extract prompt tool resolution (#28204) 2026-05-18 14:50:31 -05:00
opencode-agent[bot] e1fbed8fb6 chore: update nix node_modules hashes 2026-05-18 19:12:18 +00:00
James Long 12ae22378f fix(plugin): ask in tools from plugins returns promise instead of effect (#28217) 2026-05-18 18:57:29 +00:00
opencode-agent[bot] a88b436eec chore: generate 2026-05-18 18:42:55 +00:00
Kit Langton dbe36851bc Preview native LLM runtime stack (#27114) 2026-05-18 14:41:36 -04:00
James Long ff9d7cab5c fix(core): fix file references in workspaces (#28209) 2026-05-18 18:23:35 +00:00
Kit Langton 88681d389b Migrate provider lookup tests to instance fixtures
Migrate provider env precedence, model lookup, and default model tests to Effect-aware instance fixtures while keeping behavior unchanged.
2026-05-18 18:01:53 +00:00
Kit Langton ae2ecd1ed3 Migrate custom provider tests to instance fixtures
Migrate the next custom provider/model config tests to Effect-aware instance fixtures while keeping timing neutral and behavior unchanged.
2026-05-18 17:49:26 +00:00
Kit Langton 159d271e1e refactor(sync): publish via EffectBridge.fork for codebase consistency (#28187) 2026-05-18 13:30:41 -04:00
Kit Langton 762850dfe5 Migrate provider config tests to instance fixtures
Convert the first provider env/config/filtering tests to Effect-aware instance fixtures while keeping behavior unchanged and documenting neutral timing.
2026-05-18 13:22:59 -04:00
Kit Langton b039702e8c feat(tui): add syntax highlighting for elixir, fsharp, r, make, vim, xml, agda (#28198) 2026-05-18 13:08:08 -04:00
opencode-agent[bot] 094886c848 chore: generate 2026-05-18 17:03:44 +00:00
Shoubhit Dash dc0297f4ac refactor(session): extract reference prompt helpers (#28197) 2026-05-18 22:32:18 +05:30
Kit Langton 3ab67f3280 Stabilize watcher test readiness (#28194) 2026-05-18 12:51:46 -04:00
Shoubhit Dash a2e6bd503b refactor(reference): split materialization state (#28190) 2026-05-18 22:14:33 +05:30
Aiden Cline 05ac345696 refactor(session): move prompt reminders out of core loop (#28082) 2026-05-18 11:36:38 -05:00
opencode-agent[bot] e3feca09b3 chore: generate 2026-05-18 16:20:46 +00:00
Kit Langton 896ad7b884 Speed up targeted opencode tests
Reduce avoidable setup costs in slow opencode tests while preserving reviewed coverage and recording the benchmark evidence for follow-up test-suite work.
2026-05-18 16:18:29 +00:00
opencode-agent[bot] 0a945219a9 chore: generate 2026-05-18 16:17:46 +00:00
Shoubhit Dash 94828eb44b refactor(repository): type cache failures (#28188) 2026-05-18 21:46:18 +05:30
Shoubhit Dash 96192495ae refactor(repository): add cache service (#28184) 2026-05-18 21:25:38 +05:30
opencode-agent[bot] f7b5576bcc chore: generate 2026-05-18 15:39:33 +00:00
Kit Langton cb35493242 fix(bus): acquire PubSub subscription eagerly to close /event race (#27959) 2026-05-18 11:38:05 -04:00
Shoubhit Dash 5bfd7fd16c refactor(repository): clarify reference domain (#28182) 2026-05-18 21:04:44 +05:30
opencode-agent[bot] 2932b41e64 chore: generate 2026-05-18 15:14:13 +00:00
Shoubhit Dash eb389c58eb refactor(reference): normalize config entries (#28178) 2026-05-18 20:42:41 +05:30
Shoubhit Dash 54ff0a669b test(reference): cover configured reference contracts (#28170) 2026-05-18 20:09:27 +05:30
opencode-agent[bot] e56999fd36 chore: generate 2026-05-18 12:31:37 +00:00
Simon Klee 1124315267 run: refresh prompt layout after paste (#28164)
Pasting into the prompt textarea left its layout stale until the next edit, so the visible content did not reflect the pasted text. Mark the layout dirty on paste and notify the content-change handler once the renderer is idle so the prompt updates immediately.
2026-05-18 12:30:20 +00:00
opencode-agent[bot] 2bf3f3041f chore: update nix node_modules hashes 2026-05-18 12:03:51 +00:00
Sebastian 6e4db5666a upgrade opentui to 0.2.14 (#28090) 2026-05-18 13:44:08 +02:00
Shoubhit Dash 564cde393e fix(tui): copy pasted prompt content (#28156) 2026-05-18 17:02:34 +05:30
opencode-agent[bot] c813927bb6 chore: generate 2026-05-18 11:07:52 +00:00
Simon Klee 5970c12d90 run: replay session history on interactive resume (#26880) 2026-05-18 13:06:27 +02:00
opencode-agent[bot] 116a4e33ba chore: generate 2026-05-18 11:06:05 +00:00
Shoubhit Dash 611e48c4ac fix(tui): collapse long tool output lines (#28148) 2026-05-18 16:34:34 +05:30
Brendan Allan 836a33198e fix(ui): fix question dock overflow and message part flex layout (#28142) 2026-05-18 17:55:27 +08:00
opencode-agent[bot] 7afd477d1a chore: generate 2026-05-18 09:28:20 +00:00
SpiritChen51 fe143df151 fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993)
Co-authored-by: SpiritChen51 <spiritchen51@users.noreply.github.com>
2026-05-18 17:26:45 +08:00
Kagura e94aecaa08 fix(tui): use contrast-aware foreground for paste summary badge (#27969)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-18 08:38:40 +00:00
581 changed files with 64015 additions and 9473 deletions
+1
View File
@@ -14,3 +14,4 @@ rekram1-node
thdxr
simonklee
vimtor
starptech
+2 -2
View File
@@ -4,8 +4,8 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+8
View File
@@ -4,6 +4,14 @@
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
## Style Guide
### General Principles
+66 -43
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.4",
"version": "1.15.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -305,9 +305,23 @@
"@parcel/watcher-win32-x64": "2.5.1",
},
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.5",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +351,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +367,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +380,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +398,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.4",
"version": "1.15.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -421,6 +435,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -489,6 +504,7 @@
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -520,7 +536,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -536,9 +552,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.13",
"@opentui/keymap": ">=0.2.13",
"@opentui/solid": ">=0.2.13",
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15",
},
"optionalPeers": [
"@opentui/core",
@@ -558,7 +574,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +589,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +624,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +673,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -713,17 +729,18 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10",
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -745,9 +762,9 @@
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.65",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.66",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1106,11 +1123,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.66", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.66" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-LU3ejAzJS+4P+Qtfn9ULnsGcIPmx1tUUB2ZswFRL+EolD8US7zMljHTwGuQRUBJOjDwt7wFCMN5AR512vdY8FQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.66", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.66", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.66", "ioredis": "^5.7.0" } }, "sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.66", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.66", "", { "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-UYsrAb/5T0ZRypeN9Kmv3/ZInibGCjM6dtoiAWtfG+xKyuq8N05wmuVCXB0+XgVmUBxDWjw/S1fu4ivS0vZVuw=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1540,6 +1559,8 @@
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -1590,23 +1611,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.13", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.13", "@opentui/core-darwin-x64": "0.2.13", "@opentui/core-linux-arm64": "0.2.13", "@opentui/core-linux-x64": "0.2.13", "@opentui/core-win32-arm64": "0.2.13", "@opentui/core-win32-x64": "0.2.13" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-CFnke/uhuekinVIkcyeVF62VC35I4OTrw5MXUlKU18mMsjb9U1pzB0oBJp3us1oCHKd/KuaeCnsRz4zhEPThKA=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZREOhS54UkF2nd7keORI1NwFe2xQdX6NCA2Uft945NqsZ5+cBe4dqoPcn6Qe4WcSfysaZQBcN0eKo623v+hiNA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-4q+sjMATKbx/YzEKjuB4LaYe9+vrK0jFzuHaKY/Xg/cLXD8yZ9OpnyHQMCs75ijTBNKZwrsRhCUV174KLsb4eQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-yPQuEdSLmZFvml4B4KevbcWqWFnSJ5xQPBTUX0Y9lEGgw8xEMkJH7QBPgGSDElTihYPu8/jTZKR0pKxknxYdbw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-8i1dR80/3mz5dOGja8+ui7SHl0FkaPF60YtKJhTYEOrvkhGpQoFVAx0YhjO3YM5cYQ4CkZyeD1+bpK4v65YSyQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-5tlUt/sV/fiELecwOqeIIQOgjtdyUmNTe828JGhwRdMWCKcKz/YPMo0gwXx/HFa3lNLThA9cKADdVMnCK6N0Ow=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-okqYNxKeNeEr/4IngR+lBrOYjt/cIybLh5AjPzafyNELBTJj6yAFTRSRJxMzuEjRyY3CB3a/aBveEdZ3N8YkcA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.13", "", { "dependencies": { "@opentui/core": "0.2.13" }, "peerDependencies": { "@opentui/react": "0.2.13", "@opentui/solid": "0.2.13", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Y/IVToeiBCm5KEkt8mGP9ZdIuW9NTavOdkxo5r1/lAbo9E4O9aELD97+nRh4BccRfvRALHcVcOoYTYEGCyKHHQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/solid": ["@opentui/solid@0.2.13", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.13", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-NQSuXj0e2Epae0z5nT7HZp7jpHTKgYAl7mwsMM/HJ/6BOtDFago2QaWIoi70gI+SsvO5z2YUREHUaQ7BI/rH3g=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2972,9 +2993,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -2988,7 +3009,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="],
"effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5676,6 +5697,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+9 -2
View File
@@ -2,6 +2,7 @@ import { SECRET } from "./secret"
import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)"
const alertsDisabled = $app.stage !== "production"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
@@ -79,7 +80,7 @@ IF(
filters,
},
],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900,
}).json
}
@@ -122,7 +123,7 @@ const providerHttpErrorsQuery = () => {
},
],
formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
],
timeRange: 900,
}).json
@@ -159,6 +160,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]",
description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 300,
@@ -178,6 +180,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]",
description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change",
frequency: 300,
@@ -197,6 +200,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
new honeycomb.Trigger("LowModelTpsGo", {
name: "Low Model TPS [Go]",
description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("go"),
alertType: "on_change",
frequency: 600,
@@ -216,6 +220,7 @@ new honeycomb.Trigger("LowModelTpsGo", {
new honeycomb.Trigger("LowModelTpsZen", {
name: "Low Model TPS [Zen]",
description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("zen"),
alertType: "on_change",
frequency: 600,
@@ -235,6 +240,7 @@ new honeycomb.Trigger("LowModelTpsZen", {
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors",
description,
disabled: alertsDisabled,
queryJson: providerHttpErrorsQuery(),
alertType: "on_change",
frequency: 300,
@@ -254,6 +260,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests",
description,
disabled: alertsDisabled,
queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }],
filters: [
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LN6VLX8bXADSPGt67+YjP1Sy0QdQY+HFPq8iXN5nkck=",
"aarch64-linux": "sha256-3yhpjuCLwi7KCZZvsfabtc50hgznVfD8rcMZ9/JAnSs=",
"aarch64-darwin": "sha256-6spcWVHVBuM0SlWYTFrDvForGbtxFXxeVvJMC9thN+g=",
"x86_64-darwin": "sha256-hnD68Dexq/3EP/Sm3MnL88ezIvLLa6/nVzaV+bRpn9w="
"x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
"aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
"aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
"x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
}
}
+9 -8
View File
@@ -28,16 +28,17 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -53,9 +54,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.65",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.66",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -0,0 +1,314 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
return {
file,
additions: (seed % 9) + 1,
deletions: seed % 4,
before: code(seed, 32),
after: code(seed + 1, 38),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n",
)
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}
@@ -0,0 +1,412 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
const forbiddenText = ["Load details", "Show earlier steps"]
type SmokeState = {
ids: string[]
visibleIds: string[]
messageIds: string[]
visibleMessageIds: string[]
topVisibleId?: string
signature: string
scrollTop: number
scrollHeight: number
clientHeight: number
errorToasts: string[]
forbiddenText: string[]
}
type SmokeWindow = Window & {
__timelineSmokeState?: () => SmokeState
__timelineSmokeErrorToasts?: string[]
__timelineSmokeForbiddenText?: string[]
}
test.describe("smoke: session timeline", () => {
test.setTimeout(240_000)
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page)
await openProject(page, "SmokeProject")
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page, "smoke-project")
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
})
})
async function configureSmokePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = []
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
const idsOf = (el: HTMLElement) =>
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
smoke.__timelineSmokeState = () => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
return {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
const ids: string[] = []
const visibleIds: string[] = []
const scrollerRect = scroller.getBoundingClientRect()
let topVisibleId: string | undefined
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
const next = idsOf(el)
ids.push(...next)
const rect = el.getBoundingClientRect()
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
if (!topVisibleId) topVisibleId = next[0]
visibleIds.push(...next)
}
}
const messageIds: string[] = []
const visibleMessageIds: string[] = []
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
const rect = el.getBoundingClientRect()
const id = el.dataset.messageId
if (id) {
messageIds.push(id)
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
}
return {
id,
top: Math.round(rect.top),
bottom: Math.round(rect.bottom),
}
})
const signature = JSON.stringify({
top: Math.round(scroller.scrollTop),
height: Math.round(scroller.scrollHeight),
rows,
ids,
})
return {
ids,
visibleIds,
messageIds,
visibleMessageIds,
topVisibleId,
signature,
scrollTop: Math.round(scroller.scrollTop),
scrollHeight: Math.round(scroller.scrollHeight),
clientHeight: Math.round(scroller.clientHeight),
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
let recordFrame: number | undefined
const record = () => {
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
const text = toast.textContent?.trim()
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
}
const text = document.body?.textContent ?? ""
for (const value of ["Load details", "Show earlier steps"]) {
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
smoke.__timelineSmokeForbiddenText!.push(value)
}
}
}
const start = () => {
const root = document.documentElement ?? document.body
if (!root) return
new MutationObserver(() => {
if (recordFrame) return
recordFrame = requestAnimationFrame(() => {
recordFrame = undefined
record()
})
}).observe(root, { childList: true, subtree: true })
record()
}
if (document.documentElement ?? document.body) start()
else document.addEventListener("DOMContentLoaded", start, { once: true })
})
}
async function expectCanScrollToStart(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await pointAtTimeline(page)
const seenParts = new Set<string>()
const seenMessages = new Set<string>()
const samples: TraversalSample[] = []
let current = await timelineState(page)
let unchangedAtTop = 0
for (let attempt = 0; attempt < 600; attempt++) {
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
if (
current.scrollTop <= 1 &&
seenParts.size === expectedPartIDs.length &&
seenMessages.size === expectedMessageIDs.length
) {
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
return
}
const before = current
const changed = await scrollTimelineUp(page, current)
current = await timelineState(page)
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
else unchangedAtTop = 0
if (unchangedAtTop >= 2) break
}
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
}
async function timelineState(page: Page) {
return page.evaluate(
() =>
(window as SmokeWindow).__timelineSmokeState?.() ?? {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: [],
forbiddenText: [],
},
)
}
function timelineScroller(page: Page) {
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
}
async function pointAtTimeline(page: Page) {
const box = await timelineScroller(page).boundingBox()
if (!box) throw new Error("Timeline scroller is not visible")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
async function scrollTimelineUp(page: Page, before: SmokeState) {
return page.evaluate(
(prev) =>
new Promise<boolean>((resolve) => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
resolve(false)
return
}
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
let frames = 0
let stableFrames = 0
let last = ""
let changed = false
const check = () => {
const current = read()
if (current !== prev) changed = true
if (current === last) stableFrames++
else {
stableFrames = 0
last = current
}
if (changed && stableFrames >= 2) {
resolve(true)
return
}
frames++
if (frames >= 30) {
resolve(changed)
return
}
requestAnimationFrame(check)
}
requestAnimationFrame(check)
}),
before.signature,
)
}
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
const actualSet = new Set(actual)
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
}
function unique(values: string[]) {
return values.filter((value, index) => values.indexOf(value) === index)
}
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
for (const id of state.ids) seenParts.add(id)
for (const id of state.visibleIds) seenParts.add(id)
for (const id of state.messageIds) seenMessages.add(id)
for (const id of state.visibleMessageIds) seenMessages.add(id)
}
type TraversalSample = ReturnType<typeof sampleTraversal>
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
return {
seenParts,
seenMessages,
mounted: state.ids.length,
visible: state.visibleIds.length,
mountedMessages: unique(state.messageIds).length,
visibleMessages: unique(state.visibleMessageIds).length,
top: state.scrollTop,
height: state.scrollHeight,
first: state.ids[0],
last: state.ids.at(-1),
topVisible: state.topVisibleId,
visibleFirst: state.visibleIds[0],
visibleLast: state.visibleIds.at(-1),
}
}
function sampleSummary(samples: TraversalSample[]) {
return samples
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
.map(
(sample, index) =>
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
)
.join("\n")
}
async function waitForTimelineStable(page: Page) {
await page.waitForFunction(
() =>
new Promise<boolean>((resolve) => {
requestAnimationFrame(() => {
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() => {
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() =>
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
)
})
})
}),
)
}
async function expectSessionTimelineReady(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await waitForTimelineStable(page)
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
const currentState = await timelineState(page)
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
}
function expectCompleteScroll(
state: SmokeState,
expectedPartIDs: string[],
expectedMessageIDs: string[],
seenParts: Set<string>,
seenMessages: Set<string>,
samples: TraversalSample[],
) {
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
expect(
expectedPartIDs.filter((id) => !seenParts.has(id)),
`missing visible timeline parts\n${sampleSummary(samples)}`,
).toEqual([])
expect(
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
`missing visible messages\n${sampleSummary(samples)}`,
).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331)
}
async function openProject(page: Page, projectName: string) {
await page.goto("/")
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
}
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
await page
.locator(`a[href*="${sessionId}"]`)
.first()
.evaluate((el) => (el as HTMLElement).click())
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
}
async function expectSessionReady(page: Page, projectName: string) {
await expect(page.getByText(projectName).first()).toBeVisible()
await expect(page.getByText("Ask anything...")).toBeVisible()
}
-11
View File
@@ -1,11 +0,0 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
+18
View File
@@ -0,0 +1,18 @@
import { expect, type Page } from "@playwright/test"
export function trackPageErrors(page: Page) {
const errors: string[] = []
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
return errors
}
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
consoleErrors: [],
toastErrors: [],
forbiddenText: [],
})
}
+86
View File
@@ -0,0 +1,86 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
export interface MockServerConfig {
provider: unknown
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
},
"/project": [config.project],
"/project/current": config.project,
"/agent": [{ name: "build", mode: "primary" }],
"/vcs": { branch: "main", default_branch: "main" },
"/session": config.sessions,
}
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
return json(route, session ?? {})
}
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined
const pageData = config.pageMessages(messagesMatch[1], limit, before)
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
}
return json(route, {})
})
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
+2 -1
View File
@@ -1,10 +1,11 @@
{
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.6",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
+369 -145
View File
@@ -1,18 +1,23 @@
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -39,6 +44,8 @@ const titlebarHeight = 40
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
@@ -52,6 +59,7 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
@@ -175,162 +183,378 @@ export function Titlebar() {
return (
<header
class="h-10 shrink-0 bg-background-base relative overflow-hidden"
style={{ "min-height": minHeight() }}
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
<Switch>
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
{(_) => {
const globalSync = useGlobalSync()
const navigate = useNavigate()
type Tab = { dir: string; sessionId: string; params: any; href: string }
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
iife(() => {
if (!params.dir || !params.id) return []
return [
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = {
addTab: (tab: Tab) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
},
removeTab: (href: string) => {
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
if (index === -1) return
tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
})
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = globalSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
})
return (
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
<ChannelIndicator />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<IconButtonV2
as="a"
href="/"
variant="ghost-muted"
size="large"
class="!w-8"
state={!!useMatch(() => "/")() ? "pressed" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
stroke="currentColor"
stroke-miterlimit="10"
stroke-linecap="square"
/>
</svg>
</IconButtonV2>
<div class="flex flex-row items-center gap-2">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<button>
<div class="p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
<path
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
stroke="#808080"
stroke-linejoin="round"
/>
</TooltipKeybind>
</svg>
</div>
</button>
<div class="flex-1" />
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div>
)
}}
</Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</Match>
</Switch>
</header>
)
}
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
data-active={isActive()}
>
<a
href={props.href}
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
>
{props.title}
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
}
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</>
)
}
@@ -0,0 +1,111 @@
import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
}) {
let lastFocused: HTMLElement | undefined
const rememberFocus = () => {
const active = document.activeElement
lastFocused = active instanceof HTMLElement ? active : undefined
}
const commandDisabled = (id: string) => {
const option = props.command.options.find((option) => option.id === id)
if (!option) return true
return option.disabled ?? false
}
const runCommand = (id: string) => {
if (commandDisabled(id)) return
props.command.trigger(id)
}
const runAction = (action: DesktopMenuAction) => {
if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
void props.platform.runDesktopMenuAction?.(action)
}
const runEntry = (entry: DesktopMenuEntry) => {
if (entry.type === "separator") return
if (entry.command) {
runCommand(entry.command)
return
}
if (entry.action) {
runAction(entry.action)
return
}
if (entry.href) props.platform.openLink(entry.href)
}
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={menu.label}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.label ?? ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<span data-slot="dropdown-menu-item-label">{props.label}</span>
<span data-slot="desktop-app-menu-chevron">
<Icon name="chevron-right" size="small" />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
)
}
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
return (
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
<Show when={props.keybind}>
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
</Show>
</DropdownMenu.Item>
)
}
+596
View File
@@ -0,0 +1,596 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
const globalSync = useGlobalSync()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = globalSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}
+31 -1
View File
@@ -36,6 +36,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
type GlobalStore = {
ready: boolean
@@ -422,9 +423,18 @@ function createGlobalSync() {
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
onSuccess: () => bootstrap.refetch(),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
},
}))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return {
data: globalStore,
set,
@@ -443,6 +453,26 @@ function createGlobalSync() {
todo: {
set: setSessionTodo,
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
}
}
+6
View File
@@ -18,6 +18,7 @@ export type Locale =
| "ja"
| "pl"
| "ru"
| "uk"
| "ar"
| "no"
| "br"
@@ -45,6 +46,7 @@ const LOCALES: readonly Locale[] = [
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
@@ -65,6 +67,7 @@ const INTL: Record<Locale, string> = {
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
@@ -85,6 +88,7 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
uk: "language.uk",
ar: "language.ar",
no: "language.no",
br: "language.br",
@@ -110,6 +114,7 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
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")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
@@ -145,6 +150,7 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
+4
View File
@@ -1,6 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
@@ -82,6 +83,9 @@ export type Platform = {
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
/** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise<boolean>
+1 -454
View File
@@ -1,19 +1,8 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -172,448 +161,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const globalSync = useGlobalSync()
const sdk = useSDK()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
const target = (directory?: string) => {
if (!directory || directory === sdk.directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(sdk.directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
return globalSync.createDirSyncContext(sdk.directory)
},
})
+221
View File
@@ -0,0 +1,221 @@
export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
| "edit.cut"
| "edit.copy"
| "edit.paste"
| "edit.delete"
| "edit.selectAll"
| "view.reload"
| "view.toggleDevTools"
| "view.resetZoom"
| "view.zoomIn"
| "view.zoomOut"
| "view.toggleFullscreen"
| "window.new"
| "window.close"
| "window.minimize"
| "window.toggleMaximize"
export type DesktopMenuRole =
| "about"
| "close"
| "copy"
| "cut"
| "hide"
| "hideOthers"
| "paste"
| "quit"
| "redo"
| "reload"
| "resetZoom"
| "selectAll"
| "toggleDevTools"
| "togglefullscreen"
| "undo"
| "unhide"
| "windowMenu"
| "zoomIn"
| "zoomOut"
export type DesktopMenuItem = {
type: "item"
label?: string
command?: string
action?: DesktopMenuAction
role?: DesktopMenuRole
href?: string
accelerator?: Partial<Record<DesktopMenuPlatform, string>>
enabled?: "updater"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuSeparator = {
type: "separator"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = {
id: string
label: string
role?: DesktopMenuRole
items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[]
}
export const DESKTOP_MENU: DesktopMenu[] = [
{
id: "app",
label: "OpenCode",
platforms: ["macos"],
items: [
{ type: "item", role: "about" },
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", label: "Reload Webview", action: "view.reload" },
{ type: "item", label: "Restart", action: "app.relaunch" },
{ type: "separator" },
{ type: "item", role: "hide" },
{ type: "item", role: "hideOthers" },
{ type: "item", role: "unhide" },
{ type: "separator" },
{ type: "item", role: "quit" },
],
},
{
id: "file",
label: "File",
items: [
{
type: "item",
label: "New Session",
command: "session.new",
accelerator: { macos: "Shift+Cmd+S" },
},
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
{
type: "item",
label: "Settings",
command: "settings.open",
accelerator: { windows: "Ctrl+," },
platforms: ["windows"],
},
{
type: "item",
label: "New Window",
action: "window.new",
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
},
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
],
},
{
id: "edit",
label: "Edit",
items: [
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
{ type: "separator" },
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
{ type: "item", label: "Delete", action: "edit.delete" },
{
type: "item",
label: "Select All",
action: "edit.selectAll",
role: "selectAll",
accelerator: { windows: "Ctrl+A" },
},
],
},
{
id: "view",
label: "View",
items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
{ type: "separator" },
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
{ type: "separator" },
{
type: "item",
label: "Actual Size",
action: "view.resetZoom",
role: "resetZoom",
accelerator: { windows: "Ctrl+0" },
},
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
{ type: "separator" },
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
],
},
{
id: "go",
label: "Go",
items: [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
label: "Previous Project",
command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" },
},
{
type: "item",
label: "Next Project",
command: "project.next",
accelerator: { macos: "Cmd+Option+Down" },
},
],
},
{
id: "window",
label: "Window",
role: "windowMenu",
items: [
{ type: "item", label: "Minimize", action: "window.minimize" },
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close" },
],
},
{
id: "help",
label: "Help",
items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "separator" },
{
type: "item",
label: "Share Feedback",
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
},
{
type: "item",
label: "Report a Bug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
},
],
},
]
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}
+1
View File
@@ -416,6 +416,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
+2 -1
View File
@@ -12,12 +12,13 @@ import { dict as ko } from "./ko"
import { dict as no } from "./no"
import { dict as pl } from "./pl"
import { dict as ru } from "./ru"
import { dict as uk } from "./uk"
import { dict as th } from "./th"
import { dict as zh } from "./zh"
import { dict as zht } from "./zht"
import { dict as tr } from "./tr"
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht]
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, uk, th, tr, zh, zht]
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
describe("i18n parity", () => {
+970
View File
@@ -0,0 +1,970 @@
export const dict = {
"command.category.suggested": "Рекомендовані",
"command.category.view": "Вигляд",
"command.category.project": "Проєкт",
"command.category.provider": "Провайдер",
"command.category.server": "Сервер",
"command.category.session": "Сесія",
"command.category.theme": "Тема",
"command.category.language": "Мова",
"command.category.file": "Файл",
"command.category.context": "Контекст",
"command.category.terminal": "Термінал",
"command.category.model": "Модель",
"command.category.mcp": "MCP",
"command.category.agent": "Агент",
"command.category.permissions": "Дозволи",
"command.category.workspace": "Робоча область",
"command.category.settings": "Налаштування",
"theme.scheme.system": "Системна",
"theme.scheme.light": "Світла",
"theme.scheme.dark": "Темна",
"command.sidebar.toggle": "Перемкнути бічну панель",
"command.project.open": "Відкрити проєкт",
"command.project.previous": "Попередній проєкт",
"command.project.next": "Наступний проєкт",
"command.project.index": "Перемкнути на проєкт {{index}}",
"command.provider.connect": "Підключити провайдера",
"command.server.switch": "Перемкнути сервер",
"command.settings.open": "Відкрити налаштування",
"command.session.previous": "Попередня сесія",
"command.session.next": "Наступна сесія",
"command.session.previous.unseen": "Попередня непрочитана сесія",
"command.session.next.unseen": "Наступна непрочитана сесія",
"command.session.archive": "Архівувати сесію",
"command.palette": "Палітра команд",
"command.theme.cycle": "Перемкнути тему",
"command.theme.set": "Використати тему: {{theme}}",
"command.theme.scheme.cycle": "Перемкнути кольорову схему",
"command.theme.scheme.set": "Використати кольорову схему: {{scheme}}",
"command.language.cycle": "Перемкнути мову",
"command.language.set": "Використати мову: {{language}}",
"command.session.new": "Нова сесія",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.context.addSelection": "Додати виділення до контексту",
"command.context.addSelection.description": "Додати вибрані рядки з поточного файлу",
"command.input.focus": "Фокус на полі введення",
"command.terminal.toggle": "Перемкнути термінал",
"command.fileTree.toggle": "Перемкнути дерево файлів",
"command.review.toggle": "Перемкнути огляд",
"command.terminal.new": "Новий термінал",
"command.terminal.new.description": "Створити нову вкладку термінала",
"command.steps.toggle": "Перемкнути кроки",
"command.steps.toggle.description": "Показати або приховати кроки для поточного повідомлення",
"command.message.previous": "Попереднє повідомлення",
"command.message.previous.description": "Перейти до попереднього повідомлення користувача",
"command.message.next": "Наступне повідомлення",
"command.message.next.description": "Перейти до наступного повідомлення користувача",
"command.model.choose": "Вибрати модель",
"command.model.choose.description": "Вибрати іншу модель",
"command.mcp.toggle": "Перемкнути MCP",
"command.mcp.toggle.description": "Перемкнути MCP",
"command.agent.cycle": "Перемкнути агента",
"command.agent.cycle.description": "Перемкнути на наступного агента",
"command.agent.cycle.reverse": "Перемкнути агента в зворотному напрямку",
"command.agent.cycle.reverse.description": "Перемкнути на попереднього агента",
"command.model.variant.cycle": "Перемкнути рівень мислення",
"command.model.variant.cycle.description": "Перемкнути на наступний рівень зусилля",
"command.prompt.mode.shell": "Команда",
"command.prompt.mode.normal": "Запит",
"command.permissions.autoaccept.enable": "Автоматично приймати дозволи",
"command.permissions.autoaccept.disable": "Зупинити автоматичне прийняття дозволів",
"command.workspace.toggle": "Перемкнути робочі області",
"command.workspace.toggle.description": "Увімкнути або вимкнути декілька робочих областей на бічній панелі",
"command.session.undo": "Скасувати",
"command.session.undo.description": "Скасувати останнє повідомлення",
"command.session.redo": "Повторити",
"command.session.redo.description": "Повторити останнє скасоване повідомлення",
"command.session.compact": "Стиснути сесію",
"command.session.compact.description": "Підсумувати сесію, щоб зменшити розмір контексту",
"command.session.fork": "Відгалузити від повідомлення",
"command.session.fork.description": "Створити нову сесію з попереднього повідомлення",
"command.session.share": "Поділитися сесією",
"command.session.share.description": "Поділитися цією сесією та скопіювати URL у буфер обміну",
"command.session.unshare": "Припинити поширення сесії",
"command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди",
"palette.group.files": "Файли",
"dialog.provider.search.placeholder": "Пошук провайдерів",
"dialog.provider.empty": "Провайдерів не знайдено",
"dialog.provider.group.popular": "Популярні",
"dialog.provider.group.other": "Інші",
"dialog.provider.tag.recommended": "Рекомендовані",
"dialog.provider.opencode.note": "Відібрані моделі, включаючи Claude, GPT, Gemini та інші",
"dialog.provider.opencode.tagline": "Надійні оптимізовані моделі",
"dialog.provider.opencodeGo.tagline": "Недорога підписка для всіх",
"dialog.provider.anthropic.note": "Прямий доступ до моделей Claude, включаючи Pro та Max",
"dialog.provider.copilot.note": "Моделі AI для допомоги в кодуванні через GitHub Copilot",
"dialog.provider.openai.note": "Моделі GPT для швидких і універсальних завдань AI",
"dialog.provider.google.note": "Моделі Gemini для швидких структурованих відповідей",
"dialog.provider.openrouter.note": "Доступ до всіх підтримуваних моделей від одного провайдера",
"dialog.provider.vercel.note": "Уніфікований доступ до моделей AI з інтелектуальною маршрутизацією",
"dialog.model.select.title": "Вибрати модель",
"dialog.model.search.placeholder": "Пошук моделей",
"dialog.model.empty": "Немає результатів моделей",
"dialog.model.manage": "Керувати моделями",
"dialog.model.manage.description": "Налаштуйте, які моделі відображатимуться у виборі моделей.",
"dialog.model.manage.provider.toggle": "Перемкнути всі моделі {{provider}}",
"dialog.model.unpaid.freeModels.title": "Безкоштовні моделі від OpenCode",
"dialog.model.unpaid.addMore.title": "Додати більше моделей від популярних провайдерів",
"dialog.provider.viewAll": "Показати більше провайдерів",
"provider.connect.title": "Підключити {{provider}}",
"provider.connect.title.anthropicProMax": "Увійти з Claude Pro/Max",
"provider.connect.selectMethod": "Виберіть спосіб входу для {{provider}}.",
"provider.connect.method.apiKey": "Ключ API",
"provider.connect.status.inProgress": "Авторизація виконується...",
"provider.connect.status.waiting": "Очікування авторизації...",
"provider.connect.status.failed": "Авторизація не вдалася: {{error}}",
"provider.connect.apiKey.description":
"Введіть ключ API {{provider}}, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.apiKey.label": "Ключ API {{provider}}",
"provider.connect.apiKey.placeholder": "Ключ API",
"provider.connect.apiKey.required": "Ключ API обов'язковий",
"provider.connect.opencodeZen.line1":
"OpenCode Zen надає доступ до відібраного набору надійних оптимізованих моделей для агентів кодування.",
"provider.connect.opencodeZen.line2":
"З одним ключем API ви отримаєте доступ до таких моделей, як Claude, GPT, Gemini, GLM та інших.",
"provider.connect.opencodeZen.visit.prefix": "Відвідайте ",
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
"provider.connect.opencodeZen.visit.suffix": ", щоб отримати ключ API.",
"provider.connect.oauth.code.visit.prefix": "Відвідайте ",
"provider.connect.oauth.code.visit.link": "це посилання",
"provider.connect.oauth.code.visit.suffix":
", щоб отримати код авторизації, підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.oauth.code.label": "Код авторизації {{method}}",
"provider.connect.oauth.code.placeholder": "Код авторизації",
"provider.connect.oauth.code.required": "Код авторизації обов'язковий",
"provider.connect.oauth.code.invalid": "Недійсний код авторизації",
"provider.connect.oauth.auto.visit.prefix": "Відвідайте ",
"provider.connect.oauth.auto.visit.link": "це посилання",
"provider.connect.oauth.auto.visit.suffix":
" і введіть код нижче, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Код підтвердження",
"provider.connect.toast.connected.title": "{{provider}} підключено",
"provider.connect.toast.connected.description": "Моделі {{provider}} тепер доступні для використання.",
"provider.custom.title": "Користувацький провайдер",
"provider.custom.description.prefix": "Налаштуйте провайдера, сумісного з OpenAI. Перегляньте ",
"provider.custom.description.link": "документацію з налаштування провайдера",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "ID провайдера",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "Малі літери, цифри, дефіси або підкреслення",
"provider.custom.field.name.label": "Відображувана назва",
"provider.custom.field.name.placeholder": "Мій AI Провайдер",
"provider.custom.field.baseURL.label": "Базовий URL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "Ключ API",
"provider.custom.field.apiKey.placeholder": "Ключ API",
"provider.custom.field.apiKey.description":
"Необов'язково. Залиште порожнім, якщо ви керуєте авторизацією через заголовки.",
"provider.custom.models.label": "Моделі",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "Назва",
"provider.custom.models.name.placeholder": "Відображувана назва",
"provider.custom.models.remove": "Видалити модель",
"provider.custom.models.add": "Додати модель",
"provider.custom.headers.label": "Заголовки (необов'язково)",
"provider.custom.headers.key.label": "Заголовок",
"provider.custom.headers.key.placeholder": "Назва-Заголовка",
"provider.custom.headers.value.label": "Значення",
"provider.custom.headers.value.placeholder": "значення",
"provider.custom.headers.remove": "Видалити заголовок",
"provider.custom.headers.add": "Додати заголовок",
"provider.custom.error.providerID.required": "ID провайдера обов'язкове",
"provider.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
"provider.custom.error.providerID.exists": "ID провайдера вже існує",
"provider.custom.error.name.required": "Відображувана назва обов'язкова",
"provider.custom.error.baseURL.required": "Базовий URL обов'язковий",
"provider.custom.error.baseURL.format": "Має починатися з http:// або https://",
"provider.custom.error.required": "Обов'язково",
"provider.custom.error.duplicate": "Дублікат",
"provider.disconnect.toast.disconnected.title": "{{provider}} відключено",
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.latest": "Остання",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
"model.provider.google": "Google",
"model.provider.xai": "xAI",
"model.provider.meta": "Meta",
"model.input.text": "текст",
"model.input.image": "зображення",
"model.input.audio": "аудіо",
"model.input.video": "відео",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Дозволяє: {{inputs}}",
"model.tooltip.reasoning.allowed": "Підтримує мислення",
"model.tooltip.reasoning.none": "Без мислення",
"model.tooltip.context": "Ліміт контексту {{limit}}",
"common.search.placeholder": "Пошук",
"common.goBack": "Назад",
"common.goForward": "Вперед",
"common.loading": "Завантаження",
"common.loading.ellipsis": "...",
"common.cancel": "Скасувати",
"common.open": "Відкрити",
"common.connect": "Підключити",
"common.disconnect": "Відключити",
"common.continue": "Продовжити",
"common.submit": "Надіслати",
"common.save": "Зберегти",
"common.saving": "Збереження...",
"common.default": "За замовчуванням",
"common.attachment": "вкладення",
"prompt.placeholder.shell": "Введіть команду термінала... {{example}}",
"prompt.placeholder.normal": 'Запитайте що завгодно... "{{example}}"',
"prompt.placeholder.simple": "Запитайте що завгодно...",
"prompt.placeholder.summarizeComments": "Підсумувати коментарі…",
"prompt.placeholder.summarizeComment": "Підсумувати коментар…",
"prompt.mode.shell": "Команда",
"prompt.mode.normal": "Запит",
"prompt.mode.shell.exit": "esc для виходу",
"session.child.promptDisabled": "Сесії підагентів не можна надсилати запити.",
"session.child.backToParent": "Назад до основної сесії.",
"prompt.example.1": "Виправити TODO у коді",
"prompt.example.2": "Який технологічний стек цього проєкту?",
"prompt.example.3": "Виправити зламані тести",
"prompt.example.4": "Пояснити, як працює автентифікація",
"prompt.example.5": "Знайти та виправити вразливості безпеки",
"prompt.example.6": "Додати модульні тести для сервісу користувача",
"prompt.example.7": "Рефакторити цю функцію, щоб зробити її більш читабельною",
"prompt.example.8": "Що означає ця помилка?",
"prompt.example.9": "Допоможіть мені налагодити цю проблему",
"prompt.example.10": "Згенерувати документацію API",
"prompt.example.11": "Оптимізувати запити до бази даних",
"prompt.example.12": "Додати валідацію введення",
"prompt.example.13": "Створити новий компонент для...",
"prompt.example.14": "Як розгорнути цей проєкт?",
"prompt.example.15": "Перевірити мій код на відповідність найкращим практикам",
"prompt.example.16": "Додати обробку помилок до цієї функції",
"prompt.example.17": "Пояснити цей регулярний вираз",
"prompt.example.18": "Конвертувати це в TypeScript",
"prompt.example.19": "Додати логування по всьому коду",
"prompt.example.20": "Які залежності застарілі?",
"prompt.example.21": "Допоможіть написати скрипт міграції",
"prompt.example.22": "Реалізувати кешування для цього ендпоінта",
"prompt.example.23": "Додати посторінкову навігацію до цього списку",
"prompt.example.24": "Створити команду CLI для...",
"prompt.example.25": "Як тут працюють змінні середовища?",
"prompt.popover.emptyResults": "Немає відповідних результатів",
"prompt.popover.emptyCommands": "Немає відповідних команд",
"prompt.dropzone.label": "Перетягніть сюди зображення, PDF або текстові файли",
"prompt.dropzone.file.label": "Перетягніть, щоб @згадати файл",
"prompt.slash.badge.custom": "користувацький",
"prompt.slash.badge.skill": "навичка",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "активний",
"prompt.context.includeActiveFile": "Включити активний файл",
"prompt.context.removeActiveFile": "Видалити активний файл з контексту",
"prompt.context.removeFile": "Видалити файл з контексту",
"prompt.action.attachFile": "Додати файли",
"prompt.attachment.remove": "Видалити вкладення",
"prompt.action.send": "Надіслати",
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
"prompt.toast.worktreeCreateFailed.title": "Не вдалося створити робоче дерево",
"prompt.toast.sessionCreateFailed.title": "Не вдалося створити сесію",
"prompt.toast.shellSendFailed.title": "Не вдалося надіслати команду термінала",
"prompt.toast.commandSendFailed.title": "Не вдалося надіслати команду",
"prompt.toast.promptSendFailed.title": "Не вдалося надіслати запит",
"prompt.toast.promptSendFailed.description": "Не вдалося отримати сесію",
"dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{enabled}} з {{total}} увімкнено",
"dialog.mcp.empty": "MCP не налаштовано",
"dialog.lsp.empty": "LSP автоматично виявлені за типами файлів",
"dialog.plugins.empty": "Плагіни налаштовані в opencode.json",
"mcp.status.connected": "підключено",
"mcp.status.failed": "помилка",
"mcp.status.needs_auth": "потрібна авторизація",
"mcp.status.disabled": "вимкнено",
"mcp.auth.clickToAuthenticate": "Натисніть для автентифікації",
"dialog.fork.empty": "Немає повідомлень для відгалуження",
"dialog.directory.search.placeholder": "Пошук папок",
"dialog.directory.empty": "Папок не знайдено",
"app.server.unreachable": "Не вдалося досягти {{server}}",
"app.server.retrying": "Автоматичне повторення...",
"app.server.otherServers": "Інші сервери",
"dialog.server.title": "Сервери",
"dialog.server.description": "Перемкніть сервер OpenCode, до якого підключається ця програма.",
"dialog.server.search.placeholder": "Пошук серверів",
"dialog.server.empty": "Ще немає серверів",
"dialog.server.add.title": "Додати сервер",
"dialog.server.add.url": "Адреса сервера",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Не вдалося підключитися до сервера",
"dialog.server.add.checking": "Перевірка...",
"dialog.server.add.button": "Додати сервер",
"dialog.server.add.name": "Назва сервера (необов'язково)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Ім'я користувача (необов'язково)",
"dialog.server.add.usernamePlaceholder": "ім'я користувача",
"dialog.server.add.password": "Пароль (необов'язково)",
"dialog.server.add.passwordPlaceholder": "пароль",
"dialog.server.edit.title": "Редагувати сервер",
"dialog.server.default.title": "Сервер за замовчуванням",
"dialog.server.default.description":
"Підключатися до цього сервера під час запуску програми замість запуску локального сервера. Потребує перезапуску.",
"dialog.server.default.none": "Сервер не вибрано",
"dialog.server.default.set": "Встановити поточний сервер як сервер за замовчуванням",
"dialog.server.default.clear": "Очистити",
"dialog.server.action.remove": "Видалити сервер",
"dialog.server.menu.edit": "Редагувати",
"dialog.server.menu.default": "Встановити за замовчуванням",
"dialog.server.menu.defaultRemove": "Видалити за замовчуванням",
"dialog.server.menu.delete": "Видалити",
"dialog.server.current": "Поточний сервер",
"dialog.server.status.default": "За замовчуванням",
"server.row.noUsername": "без імені користувача",
"dialog.project.edit.title": "Редагувати проєкт",
"dialog.project.edit.name": "Назва",
"dialog.project.edit.icon": "Іконка",
"dialog.project.edit.icon.alt": "Іконка проєкту",
"dialog.project.edit.icon.hint": "Натисніть або перетягніть зображення",
"dialog.project.edit.icon.recommended": "Рекомендовано: 128x128px",
"dialog.project.edit.color": "Колір",
"dialog.project.edit.color.select": "Вибрати колір {{color}}",
"dialog.project.edit.worktree.startup": "Скрипт запуску робочої області",
"dialog.project.edit.worktree.startup.description": "Виконується після створення нової робочої області (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "напр. bun install",
"dialog.releaseNotes.action.getStarted": "Розпочати",
"dialog.releaseNotes.action.next": "Далі",
"dialog.releaseNotes.action.hideFuture": "Не показувати це в майбутньому",
"dialog.releaseNotes.media.alt": "Попередній перегляд релізу",
"context.breakdown.title": "Розподіл контексту",
"context.breakdown.note":
'Приблизний розподіл вхідних токенів. "Інше" включає визначення інструментів і накладні витрати.',
"context.breakdown.system": "Система",
"context.breakdown.user": "Користувач",
"context.breakdown.assistant": "Асистент",
"context.breakdown.tool": "Виклики інструментів",
"context.breakdown.other": "Інше",
"context.systemPrompt.title": "Системний запит",
"context.rawMessages.title": "Сировинні повідомлення",
"context.stats.session": "Сесія",
"context.stats.messages": "Повідомлення",
"context.stats.provider": "Провайдер",
"context.stats.model": "Модель",
"context.stats.limit": "Ліміт контексту",
"context.stats.totalTokens": "Всього токенів",
"context.stats.usage": "Використання",
"context.stats.inputTokens": "Вхідні токени",
"context.stats.outputTokens": "Вихідні токени",
"context.stats.reasoningTokens": "Токени мислення",
"context.stats.cacheTokens": "Токени кешу (читання/запис)",
"context.stats.userMessages": "Повідомлення користувача",
"context.stats.assistantMessages": "Повідомлення асистента",
"context.stats.totalCost": "Загальна вартість",
"context.stats.sessionCreated": "Сесію створено",
"context.stats.lastActivity": "Остання активність",
"context.usage.tokens": "Токени",
"context.usage.usage": "Використання",
"context.usage.cost": "Вартість",
"context.usage.clickToView": "Натисніть, щоб переглянути контекст",
"context.usage.view": "Переглянути використання контексту",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Мова",
"toast.language.description": "Перемкнено на {{language}}",
"toast.theme.title": "Тему змінено",
"toast.scheme.title": "Кольорова схема",
"toast.workspace.enabled.title": "Робочі області увімкнено",
"toast.workspace.enabled.description": "Кілька робочих дерев тепер відображаються на бічній панелі",
"toast.workspace.disabled.title": "Робочі області вимкнено",
"toast.workspace.disabled.description": "Тільки головне робоче дерево відображається на бічній панелі",
"toast.permissions.autoaccept.on.title": "Автоматичне прийняття дозволів",
"toast.permissions.autoaccept.on.description": "Запити дозволів будуть автоматично схвалюватися",
"toast.permissions.autoaccept.off.title": "Автоматичне прийняття дозволів зупинено",
"toast.permissions.autoaccept.off.description": "Запити дозволів вимагатимуть схвалення",
"toast.model.none.title": "Модель не вибрано",
"toast.model.none.description": "Підключіть провайдера, щоб підсумувати цю сесію",
"toast.file.loadFailed.title": "Не вдалося завантажити файл",
"toast.file.listFailed.title": "Не вдалося отримати список файлів",
"toast.context.noLineSelection.title": "Не вибрано рядків",
"toast.context.noLineSelection.description": "Спочатку виберіть діапазон рядків у вкладці файлу.",
"toast.session.share.copyFailed.title": "Не вдалося скопіювати URL у буфер обміну",
"toast.session.share.success.title": "Сесію опубліковано",
"toast.session.share.success.description": "Посилання скопійовано в буфер обміну!",
"toast.session.share.failed.title": "Не вдалося опублікувати сесію",
"toast.session.share.failed.description": "Під час публікації сесії сталася помилка",
"toast.session.unshare.success.title": "Поширення сесії припинено",
"toast.session.unshare.success.description": "Поширення сесії успішно припинено!",
"toast.session.unshare.failed.title": "Не вдалося припинити поширення сесії",
"toast.session.unshare.failed.description": "Під час припинення поширення сесії сталася помилка",
"toast.session.listFailed.title": "Не вдалося завантажити сесії для {{project}}",
"toast.project.reloadFailed.title": "Не вдалося перезавантажити {{project}}",
"toast.update.title": "Доступне оновлення",
"toast.update.description": "Нова версія OpenCode ({{version}}) тепер доступна для встановлення.",
"toast.update.action.installRestart": "Встановити та перезапустити",
"toast.update.action.notYet": "Не зараз",
"error.page.title": "Щось пішло не так",
"error.page.description": "Під час завантаження програми сталася помилка.",
"error.page.details.label": "Деталі помилки",
"error.page.action.restart": "Перезапустити",
"error.page.action.report": "Повідомити про помилку",
"error.page.action.reported": "Помилку повідомлено",
"error.page.action.checking": "Перевірка...",
"error.page.action.checkUpdates": "Перевірити оновлення",
"error.page.action.updateTo": "Оновити до {{version}}",
"error.page.circular": "[Циклічне]",
"error.page.report.prefix": "Будь ласка, повідомте про цю помилку команді OpenCode",
"error.page.report.discord": "на Discord",
"error.page.version": "Версія: {{version}}",
"error.dev.rootNotFound":
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.globalSDK.noServerAvailable": "Сервер недоступний",
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
"error.childStore.storeCreateFailed": "Не вдалося створити сховище",
"directory.error.invalidUrl": "Недійсний каталог у URL.",
"error.chain.unknown": "Невідома помилка",
"error.server.invalidConfiguration": "Недійсна конфігурація",
"error.chain.causedBy": "Причина:",
"error.chain.apiError": "Помилка API",
"error.chain.status": "Статус: {{status}}",
"error.chain.retryable": "Повторювано: {{retryable}}",
"error.chain.responseBody": "Тіло відповіді:\n{{body}}",
"error.chain.didYouMean": "Можливо, ви мали на увазі: {{suggestions}}",
"error.chain.modelNotFound": "Модель не знайдено: {{provider}}/{{model}}",
"error.chain.checkConfig": "Перевірте назви провайдерів/моделей у конфігурації (opencode.json)",
"error.chain.mcpFailed":
'Сервер MCP "{{name}}" не працює. Зверніть увагу, OpenCode ще не підтримує автентифікацію MCP.',
"error.chain.providerAuthFailed": "Автентифікація провайдера не вдалася ({{provider}}): {{message}}",
"error.chain.providerInitFailed":
'Не вдалося ініціалізувати провайдера "{{provider}}". Перевірте облікові дані та конфігурацію.',
"error.chain.configJsonInvalid": "Файл конфігурації {{path}} не є дійсним JSON(C)",
"error.chain.configJsonInvalidWithMessage": "Файл конфігурації {{path}} не є дійсним JSON(C): {{message}}",
"error.chain.configDirectoryTypo":
'Каталог "{{dir}}" у {{path}} недійсний. Перейменуйте каталог на "{{suggestion}}" або видаліть його. Це поширена помилка.',
"error.chain.configFrontmatterError": "Не вдалося розібрати frontmatter у {{path}}:\n{{message}}",
"error.chain.configInvalid": "Файл конфігурації {{path}} недійсний",
"error.chain.configInvalidWithMessage": "Файл конфігурації {{path}} недійсний: {{message}}",
"notification.permission.title": "Потрібен дозвіл",
"notification.permission.description": "{{sessionTitle}} у {{projectName}} потребує дозволу",
"notification.question.title": "Запитання",
"notification.question.description": "{{sessionTitle}} у {{projectName}} має запитання",
"notification.action.goToSession": "Перейти до сесії",
"notification.session.responseReady.title": "Відповідь готова",
"notification.session.error.title": "Помилка сесії",
"notification.session.error.fallbackDescription": "Сталася помилка",
"home.recentProjects": "Нещодавні проєкти",
"home.empty.title": "Немає нещодавніх проєктів",
"home.empty.description": "Почніть, відкривши локальний проєкт",
"session.tab.session": "Сесія",
"session.tab.review": "Огляд",
"session.tab.context": "Контекст",
"session.panel.reviewAndFiles": "Огляд і файли",
"session.review.filesChanged": "Змінено файлів: {{count}}",
"session.review.change.one": "Зміна",
"session.review.change.other": "Зміни",
"session.review.loadingChanges": "Завантаження змін...",
"session.review.empty": "У цій сесії ще немає змін",
"session.review.noVcs": "Систему контролю версій Git не виявлено, зміни не відображаються",
"session.review.noVcs.createGit.title": "Створити Git-репозиторій",
"session.review.noVcs.createGit.description": "Відстежуйте, переглядайте та скасовуйте зміни в цьому проєкті",
"session.review.noVcs.createGit.actionLoading": "Створення Git-репозиторію...",
"session.review.noVcs.createGit.action": "Створити Git-репозиторій",
"session.review.noSnapshot": "Відстеження знімків вимкнено в конфігурації, тому зміни сесії недоступні",
"session.review.noChanges": "Немає змін",
"session.review.noUncommittedChanges": "Ще немає незафіксованих змін",
"session.review.noBranchChanges": "Ще немає змін у гілці",
"session.files.selectToOpen": "Виберіть файл для відкриття",
"session.files.all": "Усі файли",
"session.files.empty": "Немає файлів",
"session.files.binaryContent": "Бінарний файл (вміст не може бути відображено)",
"session.messages.renderEarlier": "Відобразити раніші повідомлення",
"session.messages.loadingEarlier": "Завантаження раніших повідомлень...",
"session.messages.loadEarlier": "Завантажити раніші повідомлення",
"session.messages.loading": "Завантаження повідомлень...",
"session.messages.jumpToLatest": "Перейти до останніх",
"session.context.addToContext": "Додати {{selection}} до контексту",
"session.todo.title": "Завдання",
"session.todo.collapse": "Згорнути",
"session.todo.expand": "Розгорнути",
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
"session.question.progress": "{{current}} з {{total}} запитань",
"session.followupDock.summary.one": "{{count}} повідомлення в черзі",
"session.followupDock.summary.other": "{{count}} повідомлень у черзі",
"session.followupDock.sendNow": "Надіслати зараз",
"session.followupDock.edit": "Редагувати",
"session.followupDock.collapse": "Згорнути повідомлення в черзі",
"session.followupDock.expand": "Розгорнути повідомлення в черзі",
"session.revertDock.summary.one": "{{count}} скасоване повідомлення",
"session.revertDock.summary.other": "{{count}} скасованих повідомлень",
"session.revertDock.collapse": "Згорнути скасовані повідомлення",
"session.revertDock.expand": "Розгорнути скасовані повідомлення",
"session.revertDock.restore": "Відновити повідомлення",
"session.new.title": "Створити що завгодно",
"session.new.worktree.main": "Основна гілка",
"session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})",
"session.new.worktree.create": "Створити нове робоче дерево",
"session.new.lastModified": "Востаннє змінено",
"session.header.search.placeholder": "Пошук {{project}}",
"session.header.searchFiles": "Пошук файлів",
"session.header.openIn": "Відкрити в",
"session.header.open.action": "Відкрити {{app}}",
"session.header.open.ariaLabel": "Відкрити в {{app}}",
"session.header.open.menu": "Параметри відкриття",
"session.header.open.copyPath": "Копіювати шлях",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Провідник файлів",
"session.header.open.fileManager": "Файловий менеджер",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Термінал",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"status.popover.trigger": "Статус",
"status.popover.ariaLabel": "Конфігурації серверів",
"status.popover.tab.servers": "Сервери",
"status.popover.tab.mcp": "MCP",
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Плагіни",
"status.popover.action.manageServers": "Керувати серверами",
"session.share.popover.title": "Опублікувати в інтернеті",
"session.share.popover.description.shared":
"Ця сесія є публічною в інтернеті. Вона доступна будь-кому за посиланням.",
"session.share.popover.description.unshared":
"Опублікуйте сесію публічно в інтернеті. Вона буде доступна будь-кому за посиланням.",
"session.share.action.share": "Поділитися",
"session.share.action.publish": "Опублікувати",
"session.share.action.publishing": "Публікація...",
"session.share.action.unpublish": "Скасувати публікацію",
"session.share.action.unpublishing": "Скасування публікації...",
"session.share.action.view": "Переглянути",
"session.share.copy.copied": "Скопійовано",
"session.share.copy.copyLink": "Копіювати посилання",
"lsp.tooltip.none": "Немає серверів LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Завантаження запиту...",
"terminal.loading": "Завантаження термінала...",
"terminal.title": "Термінал",
"terminal.title.numbered": "Термінал {{number}}",
"terminal.close": "Закрити термінал",
"terminal.connectionLost.title": "З'єднання втрачено",
"terminal.connectionLost.abnormalClose": "WebSocket закрито аномально: {{code}}",
"terminal.connectionLost.description":
"З'єднання з терміналом було перервано. Це може статися під час перезапуску сервера.",
"common.closeTab": "Закрити вкладку",
"common.dismiss": "Відхилити",
"common.moreCountSuffix": " (ще {{count}})",
"common.requestFailed": "Запит не виконано",
"common.moreOptions": "Більше опцій",
"common.learnMore": "Дізнатися більше",
"common.rename": "Перейменувати",
"common.reset": "Скинути",
"common.archive": "Архівувати",
"common.delete": "Видалити",
"common.close": "Закрити",
"common.edit": "Редагувати",
"common.loadMore": "Завантажити більше",
"common.key.esc": "ESC",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Пробіл",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "невідомо",
"common.time.justNow": "Щойно",
"common.time.minutesAgo.short": "{{count}} хв тому",
"common.time.hoursAgo.short": "{{count}} год тому",
"common.time.daysAgo.short": "{{count}} дн тому",
"sidebar.menu.toggle": "Перемкнути меню",
"sidebar.nav.projectsAndSessions": "Проєкти та сесії",
"sidebar.settings": "Налаштування",
"sidebar.help": "Довідка",
"sidebar.workspaces.enable": "Увімкнути робочі області",
"sidebar.workspaces.disable": "Вимкнути робочі області",
"sidebar.gettingStarted.title": "Початок роботи",
"sidebar.gettingStarted.line1": "OpenCode містить безкоштовні моделі, тому ви можете почати негайно.",
"sidebar.gettingStarted.line2":
"Підключіть будь-якого провайдера, щоб використовувати моделі, включаючи Claude, GPT, Gemini тощо.",
"sidebar.project.recentSessions": "Нещодавні сесії",
"sidebar.project.viewAllSessions": "Переглянути всі сесії",
"sidebar.project.clearNotifications": "Очистити сповіщення",
"sidebar.empty.title": "Немає відкритих проєктів",
"sidebar.empty.description": "Відкрийте проєкт, щоб почати",
"debugBar.ariaLabel": "Діагностика продуктивності розробки",
"debugBar.na": "н/д",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Останній завершений перехід маршруту, що торкається сторінки сесії, виміряний від запуску маршрутизатора до першого відображення після стабілізації.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Поточна кількість кадрів за секунду за останні 5 секунд.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Найгірший час кадру за останні 5 секунд.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Кадри понад 32 мс за останні 5 секунд.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Заблокований час і кількість довгих завдань за останні 5 секунд. Макс. завдання: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Найгірша спостережувана затримка введення за останні 5 секунд.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Приблизна тривалість взаємодії за останні 5 секунд. Це схоже на INP, а не на офіційний Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Сукупний зсув макета за весь час роботи програми.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Використана купа JS проти ліміту купи. Тільки Chromium.",
"debugBar.mem.tip": "Використана купа JS проти ліміту купи. {{used}} з {{limit}}.",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Робочий стіл",
"settings.section.server": "Сервер",
"settings.tab.general": "Загальні",
"settings.tab.shortcuts": "Скорочення",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "Інтеграція WSL",
"settings.desktop.wsl.description": "Запускати сервер OpenCode всередині WSL на Windows.",
"settings.general.section.appearance": "Зовнішній вигляд",
"settings.general.section.advanced": "Додатково",
"settings.general.section.notifications": "Системні сповіщення",
"settings.general.section.updates": "Оновлення",
"settings.general.section.sounds": "Звукові ефекти",
"settings.general.section.feed": "Стрічка",
"settings.general.section.display": "Дисплей",
"settings.general.row.language.title": "Мова",
"settings.general.row.language.description": "Змінити мову інтерфейсу OpenCode",
"settings.general.row.shell.title": "Командна оболонка термінала",
"settings.general.row.shell.description":
"Виберіть оболонку для термінала. Сумісні оболонки також використовуються для викликів інструментів агента.",
"settings.general.row.shell.autoDefault": "Авто (за замовчуванням)",
"settings.general.row.shell.terminalOnly": "тільки термінал",
"settings.general.row.appearance.title": "Зовнішній вигляд",
"settings.general.row.appearance.description": "Налаштуйте вигляд OpenCode на вашому пристрої",
"settings.general.row.colorScheme.title": "Кольорова схема",
"settings.general.row.colorScheme.description": "Виберіть, чи OpenCode використовує системну, світлу або темну тему",
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Налаштуйте тему OpenCode.",
"settings.general.row.font.title": "Шрифт коду",
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
"settings.general.row.terminalFont.title": "Шрифт термінала",
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
"settings.general.row.followup.title": "Поведінка продовження",
"settings.general.row.followup.description": "Виберіть, чи продовження виконується негайно, чи чекає в черзі",
"settings.general.row.followup.option.queue": "Черга",
"settings.general.row.followup.option.steer": "Керування",
"settings.general.row.showFileTree.title": "Дерево файлів",
"settings.general.row.showFileTree.description":
"Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showNavigation.title": "Елементи навігації",
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
"settings.general.row.showSearch.title": "Палітра команд",
"settings.general.row.showSearch.description":
"Показувати кнопку пошуку та палітри команд у заголовку робочого столу",
"settings.general.row.showTerminal.title": "Термінал",
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
"settings.general.row.showStatus.title": "Статус сервера",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку робочого столу",
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
"settings.general.row.shellToolPartsExpanded.description":
"Показувати частини інструменту оболонки розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.showSessionProgressBar.title": "Показувати індикатор прогресу сесії",
"settings.general.row.showSessionProgressBar.description":
"Відображати анімований індикатор прогресу вгорі сесії, коли агент працює",
"settings.general.row.wayland.title": "Використовувати нативний Wayland",
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",
"settings.general.row.wayland.tooltip":
"На Linux з моніторами з різною частотою оновлення нативний Wayland може бути більш стабільним.",
"settings.general.row.releaseNotes.title": "Нотатки до релізу",
"settings.general.row.releaseNotes.description": 'Показувати спливаючі вікна "Що нового" після оновлень',
"settings.updates.row.startup.title": "Перевіряти оновлення під час запуску",
"settings.updates.row.startup.description": "Автоматично перевіряти наявність оновлень під час запуску OpenCode",
"settings.updates.row.check.title": "Перевірити оновлення",
"settings.updates.row.check.description": "Вручну перевірити наявність оновлень і встановити, якщо доступні",
"settings.updates.action.checkNow": "Перевірити зараз",
"settings.updates.action.checking": "Перевірка...",
"settings.updates.toast.latest.title": "У вас актуальна версія",
"settings.updates.toast.latest.description": "Ви використовуєте останню версію OpenCode.",
"sound.option.none": "Немає",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",
"sound.option.alert04": "Alert 04",
"sound.option.alert05": "Alert 05",
"sound.option.alert06": "Alert 06",
"sound.option.alert07": "Alert 07",
"sound.option.alert08": "Alert 08",
"sound.option.alert09": "Alert 09",
"sound.option.alert10": "Alert 10",
"sound.option.bipbop01": "Bip-bop 01",
"sound.option.bipbop02": "Bip-bop 02",
"sound.option.bipbop03": "Bip-bop 03",
"sound.option.bipbop04": "Bip-bop 04",
"sound.option.bipbop05": "Bip-bop 05",
"sound.option.bipbop06": "Bip-bop 06",
"sound.option.bipbop07": "Bip-bop 07",
"sound.option.bipbop08": "Bip-bop 08",
"sound.option.bipbop09": "Bip-bop 09",
"sound.option.bipbop10": "Bip-bop 10",
"sound.option.staplebops01": "Staplebops 01",
"sound.option.staplebops02": "Staplebops 02",
"sound.option.staplebops03": "Staplebops 03",
"sound.option.staplebops04": "Staplebops 04",
"sound.option.staplebops05": "Staplebops 05",
"sound.option.staplebops06": "Staplebops 06",
"sound.option.staplebops07": "Staplebops 07",
"sound.option.nope01": "Nope 01",
"sound.option.nope02": "Nope 02",
"sound.option.nope03": "Nope 03",
"sound.option.nope04": "Nope 04",
"sound.option.nope05": "Nope 05",
"sound.option.nope06": "Nope 06",
"sound.option.nope07": "Nope 07",
"sound.option.nope08": "Nope 08",
"sound.option.nope09": "Nope 09",
"sound.option.nope10": "Nope 10",
"sound.option.nope11": "Nope 11",
"sound.option.nope12": "Nope 12",
"sound.option.yup01": "Yup 01",
"sound.option.yup02": "Yup 02",
"sound.option.yup03": "Yup 03",
"sound.option.yup04": "Yup 04",
"sound.option.yup05": "Yup 05",
"sound.option.yup06": "Yup 06",
"settings.general.notifications.agent.title": "Агент",
"settings.general.notifications.agent.description":
"Показувати системне сповіщення, коли агент завершує роботу або потребує уваги",
"settings.general.notifications.permissions.title": "Дозволи",
"settings.general.notifications.permissions.description": "Показувати системне сповіщення, коли потрібен дозвіл",
"settings.general.notifications.errors.title": "Помилки",
"settings.general.notifications.errors.description": "Показувати системне сповіщення, коли виникає помилка",
"settings.general.sounds.agent.title": "Агент",
"settings.general.sounds.agent.description": "Відтворювати звук, коли агент завершує роботу або потребує уваги",
"settings.general.sounds.permissions.title": "Дозволи",
"settings.general.sounds.permissions.description": "Відтворювати звук, коли потрібен дозвіл",
"settings.general.sounds.errors.title": "Помилки",
"settings.general.sounds.errors.description": "Відтворювати звук, коли виникає помилка",
"settings.shortcuts.title": "Скорочення клавіш",
"settings.shortcuts.reset.button": "Скинути до стандартних",
"settings.shortcuts.reset.toast.title": "Скорочення скинуто",
"settings.shortcuts.reset.toast.description": "Скорочення клавіш були скинуті до стандартних.",
"settings.shortcuts.conflict.title": "Скорочення вже використовується",
"settings.shortcuts.conflict.description": "{{keybind}} вже призначено для {{titles}}.",
"settings.shortcuts.unassigned": "Не призначено",
"settings.shortcuts.pressKeys": "Натисніть клавіші",
"settings.shortcuts.search.placeholder": "Пошук скорочень",
"settings.shortcuts.search.empty": "Скорочень не знайдено",
"settings.shortcuts.group.general": "Загальні",
"settings.shortcuts.group.session": "Сесія",
"settings.shortcuts.group.navigation": "Навігація",
"settings.shortcuts.group.modelAndAgent": "Модель та агент",
"settings.shortcuts.group.terminal": "Термінал",
"settings.shortcuts.group.prompt": "Запит",
"settings.providers.title": "Провайдери",
"settings.providers.description": "Налаштування провайдерів будуть доступні тут.",
"settings.providers.section.connected": "Підключені провайдери",
"settings.providers.connected.empty": "Немає підключених провайдерів",
"settings.providers.connected.environmentDescription": "Підключено зі змінних середовища",
"settings.providers.section.popular": "Популярні провайдери",
"settings.providers.custom.description": "Додайте провайдера, сумісного з OpenAI, за базовим URL.",
"settings.providers.tag.environment": "Середовище",
"settings.providers.tag.config": "Конфігурація",
"settings.providers.tag.custom": "Користувацький",
"settings.providers.tag.other": "Інше",
"settings.models.title": "Моделі",
"settings.models.description": "Налаштування моделей будуть доступні тут.",
"settings.agents.title": "Агенти",
"settings.agents.description": "Налаштування агентів будуть доступні тут.",
"settings.commands.title": "Команди",
"settings.commands.description": "Налаштування команд будуть доступні тут.",
"settings.mcp.title": "MCP",
"settings.mcp.description": "Налаштування MCP будуть доступні тут.",
"settings.permissions.title": "Дозволи",
"settings.permissions.description": "Керуйте тим, які інструменти сервер може використовувати за замовчуванням.",
"settings.permissions.section.tools": "Інструменти",
"settings.permissions.toast.updateFailed.title": "Не вдалося оновити дозволи",
"settings.permissions.action.allow": "Дозволити",
"settings.permissions.action.ask": "Запитувати",
"settings.permissions.action.deny": "Заборонити",
"settings.permissions.tool.read.title": "Читання",
"settings.permissions.tool.read.description": "Читання файлу (відповідає шляху файлу)",
"settings.permissions.tool.edit.title": "Редагування",
"settings.permissions.tool.edit.description": "Зміна файлів, включаючи редагування, запис і патчі",
"settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Зіставлення файлів за допомогою glob-шаблонів",
"settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "Пошук вмісту файлів за допомогою регулярних виразів",
"settings.permissions.tool.list.title": "Список",
"settings.permissions.tool.list.description": "Список файлів у каталозі",
"settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Запуск команд оболонки",
"settings.permissions.tool.task.title": "Завдання",
"settings.permissions.tool.task.description": "Запуск підагентів",
"settings.permissions.tool.skill.title": "Навичка",
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Пошук в інтернеті",
"settings.permissions.tool.external_directory.title": "Зовнішній каталог",
"settings.permissions.tool.external_directory.description": "Доступ до файлів за межами каталогу проєкту",
"settings.permissions.tool.doom_loop.title": "Цикл приреченості",
"settings.permissions.tool.doom_loop.description":
"Виявлення повторюваних викликів інструментів з однаковими вхідними даними",
"session.delete.failed.title": "Не вдалося видалити сесію",
"session.delete.title": "Видалити сесію",
"session.delete.confirm": 'Видалити сесію "{{name}}"?',
"session.delete.button": "Видалити сесію",
"workspace.new": "Нова робоча область",
"workspace.type.local": "локальна",
"workspace.type.sandbox": "пісочниця",
"workspace.create.failed.title": "Не вдалося створити робочу область",
"workspace.delete.failed.title": "Не вдалося видалити робочу область",
"workspace.resetting.title": "Скидання робочої області",
"workspace.resetting.description": "Це може зайняти хвилину.",
"workspace.reset.failed.title": "Не вдалося скинути робочу область",
"workspace.reset.success.title": "Робочу область скинуто",
"workspace.reset.success.description": "Робоча область тепер відповідає гілці за замовчуванням.",
"workspace.error.stillPreparing": "Робоча область все ще готується",
"workspace.status.checking": "Перевірка незлитих змін...",
"workspace.status.error": "Не вдалося перевірити статус git.",
"workspace.status.clean": "Незлитих змін не виявлено.",
"workspace.status.dirty": "Виявлено незлиті зміни в цій робочій області.",
"workspace.delete.title": "Видалити робочу область",
"workspace.delete.confirm": 'Видалити робочу область "{{name}}"?',
"workspace.delete.button": "Видалити робочу область",
"workspace.reset.title": "Скинути робочу область",
"workspace.reset.confirm": 'Скинути робочу область "{{name}}"?',
"workspace.reset.button": "Скинути робочу область",
"workspace.reset.archived.none": "Жодна активна сесія не буде заархівована.",
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
}
+58
View File
@@ -1,4 +1,5 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
@@ -53,6 +54,63 @@
container-name: getting-started;
}
[data-component="dropdown-menu-content"].desktop-app-menu,
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
min-width: 160px;
padding: 2px;
}
[data-component="dropdown-menu-content"].desktop-app-menu {
width: 160px;
}
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
width: max-content;
min-width: 240px;
max-width: min(320px, calc(100vw - 24px));
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--text-weak);
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
min-height: 28px;
padding: 0 12px;
gap: 8px;
font-weight: var(--font-weight-regular);
line-height: 1;
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
white-space: nowrap;
}
[data-slot="desktop-app-menu-keybind"] {
margin-left: auto;
color: var(--text-weak);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-regular);
white-space: nowrap;
}
[data-slot="desktop-app-menu-chevron"] {
display: flex;
margin-left: auto;
color: var(--icon-base);
}
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;
+11 -1
View File
@@ -8,6 +8,7 @@ import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
@@ -40,6 +41,15 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
)
}
export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString"))
export type ProjectDirString = Schema.Schema.Type<typeof ProjectDirString>
export function decodeDirectory(dir: string): ProjectDirString | undefined {
const decoded = decode64(dir)
if (!decoded) return
return ProjectDirString.make(decoded)
}
export default function Layout(props: ParentProps) {
const params = useParams()
const language = useLanguage()
@@ -48,7 +58,7 @@ export default function Layout(props: ParentProps) {
const resolved = createMemo(() => {
if (!params.dir) return ""
return decode64(params.dir) ?? ""
return decodeDirectory(params.dir) ?? ""
})
createEffect(() => {
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
@@ -1,19 +1,4 @@
import {
createEffect,
createMemo,
createSignal,
For,
Index,
Match,
Switch,
on,
onCleanup,
Show,
mapArray,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
@@ -995,26 +980,26 @@ export function MessageTimeline(props: {
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
if (untrack(row).group.type === "context") {
const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
if (row.group.type === "context") {
const parts = createMemo(() => {
const group = row().group
const group = row.group
if (group.type !== "context") return emptyTools
return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
})
return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} />
return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
}
const message = createMemo(() => {
const group = row().group
const group = row.group
if (group.type !== "part") return
return messageByID().get(group.ref.messageID)
})
const part = createMemo(() => {
const group = row().group
const group = row.group
if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID)
})
@@ -1027,8 +1012,8 @@ export function MessageTimeline(props: {
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(
part(),
settings.general.shellToolPartsExpanded(),
@@ -1043,25 +1028,25 @@ export function MessageTimeline(props: {
)
}
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
const anchor = () => {
const row = input.row()
const row = input.row
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
}
const previousUserMessage = () => {
const row = input.row()
const row = input.row
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
}
const previousAssistantPart = () => {
const row = input.row()
const row = input.row
return row._tag === "AssistantPart" && row.previousAssistantPart
}
return (
<div
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
data-message-id={input.row().userMessageID}
data-timeline-row={input.row()._tag}
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row.userMessageID}
data-timeline-row={input.row._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
@@ -1077,15 +1062,14 @@ export function MessageTimeline(props: {
)
}
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
switch (row()._tag) {
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
switch (row._tag) {
case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() =>
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
)
return (
<TimelineRowFrame row={commentStripRow}>
<TimelineRowFrame row={row}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
@@ -1118,22 +1102,17 @@ export function MessageTimeline(props: {
)
}
case "UserMessage": {
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(userMessageRow().userMessageID)
const m = messageByID().get(row.userMessageID)
if (m?.role === "user") return m
})
return (
<TimelineRowFrame row={userMessageRow}>
<TimelineRowFrame row={row}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-content" aria-live="off">
<Message
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
/>
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
</div>
</div>
)}
@@ -1142,14 +1121,13 @@ export function MessageTimeline(props: {
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={turnDividerRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
</div>
@@ -1158,27 +1136,22 @@ export function MessageTimeline(props: {
)
}
case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={assistantPartRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
>
{renderAssistantPartGroup(assistantPartRow)}
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
{renderAssistantPartGroup(row)}
</div>
</div>
</TimelineRowFrame>
)
}
case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={thinkingRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
reasoningHeading={row.reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
@@ -1186,32 +1159,29 @@ export function MessageTimeline(props: {
)
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={retryRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
<SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
</div>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return (
<TimelineRowFrame row={diffSummaryRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
<TimelineDiffSummaryRow diffs={row.diffs} />
</div>
</TimelineRowFrame>
)
}
case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={errorRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{errorRow().text}
{row.text}
</Card>
</div>
</TimelineRowFrame>
@@ -1223,7 +1193,11 @@ export function MessageTimeline(props: {
}
function TimelineRowView(props: { rowKey: string }) {
return <Show when={timelineRowByKey().get(props.rowKey)}>{(item) => renderTimelineRow(item)}</Show>
return (
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
{(item) => renderTimelineRow(item)}
</Show>
)
}
return (
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.6",
"type": "module",
"license": "MIT",
"scripts": {
@@ -0,0 +1,289 @@
[data-component="go-credit-confirm"] {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: min(34rem, calc(100vw - var(--space-8)));
p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.6;
}
[data-slot="usage-preview"] {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
}
[data-slot="usage-preview-item"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-preview-header"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
[data-slot="usage-preview-label"] {
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: 500;
}
[data-slot="usage-preview-value"] {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
white-space: nowrap;
}
[data-slot="usage-preview-after-value"] {
color: var(--color-accent);
font-weight: 600;
}
[data-slot="usage-preview-progress"] {
position: relative;
height: 8px;
overflow: hidden;
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
}
[data-slot="usage-preview-before"],
[data-slot="usage-preview-after"] {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: var(--border-radius-sm);
}
[data-slot="usage-preview-before"] {
background-color: var(--color-border);
}
[data-slot="usage-preview-after"] {
background-color: var(--color-accent);
transition: width 0.35s ease;
}
[data-slot="usage-preview-reset"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="modal-actions"] {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
}
[data-slot="invite-link-box"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
> div {
display: flex;
align-items: center;
gap: var(--space-3);
border-radius: var(--border-radius-sm);
@media (max-width: 40rem) {
align-items: stretch;
flex-direction: column;
}
}
code {
flex: 1;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 40rem) {
padding: var(--space-2-5);
font-size: var(--font-size-xs);
}
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 130px;
white-space: nowrap;
@media (max-width: 40rem) {
min-width: 96px;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
}
}
}
[data-slot="instructions"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
ol {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding-left: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
list-style-position: inside;
line-height: 1.5;
}
}
[data-component="go-referral-section"] {
[data-component="go-referral-overview"] {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
}
[data-component="go-referral-overview"] + [data-slot="section-title"] {
margin-top: var(--space-4);
}
[data-slot="referrals-table"] {
overflow-x: auto;
}
[data-component="empty-state"] {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
[data-slot="referrals-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
&:nth-child(1) {
width: 120px;
}
&:nth-child(3) {
width: 180px;
}
&:nth-child(4) {
width: 140px;
}
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="referral-amount"] {
color: var(--color-text);
font-weight: 500;
}
&[data-slot="referral-source"] {
color: var(--color-text-secondary);
font-family: var(--font-sans);
white-space: nowrap;
}
&[data-slot="referral-action"] {
text-align: right;
font-family: var(--font-sans);
white-space: nowrap;
button {
min-width: 96px;
}
}
}
tbody tr {
&[data-status="applied"] {
td:not([data-slot="referral-action"]) {
opacity: 0.68;
}
}
&[data-status="pending"] {
td[data-slot="referral-amount"],
td[data-slot="referral-date"] {
color: var(--color-text-muted);
}
td[data-slot="referral-source"] {
color: var(--color-text);
}
}
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
}
}
}
@@ -0,0 +1,300 @@
import { action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import "./go-referral.css"
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
type GoReferralReward = GoReferralSummary["rewards"][number]
type GoLiteSubscription = Awaited<ReturnType<typeof queryLiteSubscription>>
type GoReferralUsagePreview = NonNullable<Awaited<ReturnType<typeof Referral.usagePreview>>>
type GoReferralUsagePreviewItem = GoReferralUsagePreview["rollingUsage"]
const emptyUsagePreview = {
rollingUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
weeklyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
monthlyUsage: { beforePercent: 0, afterPercent: 0, resetInSec: 0 },
} satisfies GoReferralUsagePreview
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(() => Referral.summary(), workspaceID)
}, "go.referral.get")
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
"use server"
if (!referralID) return null
return withActor(() => Referral.usagePreview({ referralID }), workspaceID)
}, "go.referral.usagePreview")
export const applyGoReferralReward = action(async (workspaceID: string, referralID: string) => {
"use server"
return json(await withActor(() => Referral.applyReward({ referralID }), workspaceID), {
revalidate: [queryGoReferral.key, queryGoReferralUsagePreview.key, queryLiteSubscription.key],
})
}, "go.referral.reward.apply")
function currentUsagePreview(usage: { resetInSec: number; usagePercent: number }) {
return {
beforePercent: usage.usagePercent,
afterPercent: usage.usagePercent,
resetInSec: usage.resetInSec,
}
}
function formatCurrency(amount: number) {
if (amount % 100 === 0) return `$${amount / 100}`
return `$${(amount / 100).toFixed(2)}`
}
function formatDate(value: string | Date, locale: string) {
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }).format(new Date(value))
}
function rewardDescriptionKey(source: GoReferralReward["source"]) {
if (source === "invitee") return "workspace.referral.reward.description.invitee" as const
return "workspace.referral.reward.description.inviter" as const
}
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
return "workspace.referral.reward.action.view" as const
}
function CopyInviteLink(props: { summary: GoReferralSummary }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const event = getRequestEvent()
const origin = event
? new URL(event.request.url).origin
: typeof window === "object"
? window.location.origin
: undefined
const inviteUrl = createMemo(() => {
const path = `/go?ref=${props.summary.referralCode}`
if (!origin) return path
return new URL(path, origin).toString()
})
async function copy() {
if (typeof navigator !== "object") return
await navigator.clipboard.writeText(inviteUrl())
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<div data-slot="invite-link-box">
<div>
<code title={inviteUrl()}>{inviteUrl()}</code>
<button type="button" onClick={copy}>
<Show
when={copied()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copyLink")}
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.referral.copied")}
</Show>
</button>
</div>
</div>
)
}
export function GoReferralSection(props: {
workspaceID: string
summary: GoReferralSummary
lite: GoLiteSubscription | undefined
}) {
const i18n = useI18n()
const language = useLanguage()
const apply = useAction(applyGoReferralReward)
const submission = useSubmission(applyGoReferralReward)
const [selected, setSelected] = createSignal<GoReferralReward>()
const [preview, setPreview] = createSignal<GoReferralUsagePreview | null>()
const displayPreview = createMemo(() => {
const loaded = preview()
if (loaded) return loaded
const current = props.lite
if (!current) return emptyUsagePreview
return {
rollingUsage: currentUsagePreview(current.rollingUsage),
weeklyUsage: currentUsagePreview(current.weeklyUsage),
monthlyUsage: currentUsagePreview(current.monthlyUsage),
} satisfies GoReferralUsagePreview
})
createEffect(() => {
const reward = selected()
if (!reward) {
setPreview(undefined)
return
}
const request = { cancelled: false }
setPreview(undefined)
queryGoReferralUsagePreview(props.workspaceID, reward.id).then((result) => {
if (request.cancelled) return
setPreview(result)
})
onCleanup(() => {
request.cancelled = true
})
})
async function onApply() {
const reward = selected()
if (!reward) return
await apply(props.workspaceID, reward.id)
setSelected(undefined)
}
return (
<>
<Show when={props.lite || props.summary.hasReferral}>
<section data-component="go-referral-section">
<Show when={props.lite}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.referral.overview.title")}</h2>
<p>{i18n.t("workspace.referral.overview.subtitle")}</p>
</div>
<div data-component="go-referral-overview">
<CopyInviteLink summary={props.summary} />
<div data-slot="instructions">
<ol>
<li>{i18n.t("workspace.referral.instructions.share")}</li>
<li>{i18n.t("workspace.referral.instructions.subscribe")}</li>
<li>{i18n.t("workspace.referral.instructions.claim")}</li>
</ol>
</div>
</div>
</Show>
<Show when={props.summary.hasReferral}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.referral.rewards.title")}</h2>
<p>{i18n.t("workspace.referral.rewards.description")}</p>
</div>
<div data-slot="referrals-table">
<table data-slot="referrals-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.referral.table.reward")}</th>
<th>{i18n.t("workspace.referral.table.referral")}</th>
<th>{i18n.t("workspace.referral.table.date")}</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={props.summary.rewards}>
{(reward) => {
const earnedAt = () => formatDate(reward.timeCreated, language.tag(language.locale()))
return (
<tr data-status={reward.status} data-source={reward.source}>
<td data-slot="referral-amount">{formatCurrency(reward.amount)}</td>
<td data-slot="referral-source">
{i18n.t(rewardDescriptionKey(reward.source), { email: reward.email ?? "" })}
</td>
<td data-slot="referral-date" title={earnedAt()}>
{earnedAt()}
</td>
<td data-slot="referral-action">
<button
type="button"
disabled={reward.status !== "available" || !props.lite || submission.pending}
onClick={() => setSelected(reward)}
>
{i18n.t(rewardActionKey(reward, !!props.lite))}
</button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</Show>
</section>
</Show>
<Modal
open={!!selected()}
onClose={() => setSelected(undefined)}
title={i18n.t("workspace.referral.apply.confirmTitle")}
>
<div data-component="go-credit-confirm">
<p>
{i18n.t("workspace.referral.apply.confirmBody", {
amount: formatCurrency(selected()?.amount ?? 0),
})}
</p>
<GoReferralUsagePreview preview={displayPreview()} />
<div data-slot="modal-actions">
<button type="button" onClick={() => setSelected(undefined)}>
{i18n.t("common.cancel")}
</button>
<button type="button" data-color="primary" disabled={submission.pending} onClick={onApply}>
{submission.pending ? i18n.t("workspace.lite.loading") : i18n.t("workspace.referral.apply.confirmAction")}
</button>
</div>
</div>
</Modal>
</>
)
}
function GoReferralUsagePreview(props: { preview: GoReferralUsagePreview }) {
const i18n = useI18n()
return (
<div data-slot="usage-preview">
<GoReferralUsagePreviewRow
label={i18n.t("workspace.lite.subscription.rollingUsage")}
usage={props.preview.rollingUsage}
/>
<GoReferralUsagePreviewRow
label={i18n.t("workspace.lite.subscription.weeklyUsage")}
usage={props.preview.weeklyUsage}
/>
<GoReferralUsagePreviewRow
label={i18n.t("workspace.lite.subscription.monthlyUsage")}
usage={props.preview.monthlyUsage}
/>
</div>
)
}
function GoReferralUsagePreviewRow(props: { label: string; usage: GoReferralUsagePreviewItem }) {
const i18n = useI18n()
return (
<div data-slot="usage-preview-item">
<div data-slot="usage-preview-header">
<span data-slot="usage-preview-label">{props.label}</span>
<span data-slot="usage-preview-value">
<span>{props.usage.beforePercent}%</span>
<span aria-hidden="true">-&gt;</span>
<span data-slot="usage-preview-after-value">{props.usage.afterPercent}%</span>
</span>
</div>
<div data-slot="usage-preview-progress">
<div data-slot="usage-preview-before" style={{ width: `${props.usage.beforePercent}%` }} />
<div data-slot="usage-preview-after" style={{ width: `${props.usage.afterPercent}%` }} />
</div>
<span data-slot="usage-preview-reset">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
</span>
</div>
)
}
@@ -55,6 +55,61 @@
@media (prefers-color-scheme: dark) {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--color-surface-hover);
border-color: var(--color-accent);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
&[data-color="primary"] {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-text);
&:hover:not(:disabled) {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
&[data-color="ghost"] {
background-color: transparent;
border-color: transparent;
color: var(--color-text-muted);
&:hover:not(:disabled) {
background-color: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
}
}
}
[data-slot="title"] {
@@ -64,4 +119,16 @@
color: var(--color-text);
text-align: center;
}
[data-slot="content"][data-variant="black"] {
background-color: #000;
border-color: rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
font-family: var(--font-mono);
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-family: var(--font-mono);
}
}
}
+30 -8
View File
@@ -1,3 +1,4 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { JSX, Show } from "solid-js"
import "./modal.css"
@@ -5,20 +6,41 @@ interface ModalProps {
open: boolean
onClose: () => void
title?: string
variant?: "black"
children: JSX.Element
}
export function Modal(props: ModalProps) {
return (
<Show when={props.open}>
<div data-component="modal" data-slot="overlay" onClick={props.onClose}>
<div data-slot="content" onClick={(e) => e.stopPropagation()}>
<Show when={props.title}>
<h2 data-slot="title">{props.title}</h2>
</Show>
{props.children}
</div>
</div>
<Kobalte
modal
open={props.open}
preventScroll={false}
onOpenChange={(open) => {
if (!open) props.onClose()
}}
>
<Kobalte.Portal>
<Kobalte.Overlay data-component="modal" data-slot="overlay" onClick={props.onClose}>
<Kobalte.Content
data-slot="content"
data-variant={props.variant}
onClick={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => {
e.preventDefault()
const target = e.currentTarget as HTMLElement | null
target?.focus({ preventScroll: true })
}}
>
<Show when={props.title}>
<Kobalte.Title data-slot="title">{props.title}</Kobalte.Title>
</Show>
{props.children}
</Kobalte.Content>
</Kobalte.Overlay>
</Kobalte.Portal>
</Kobalte>
</Show>
)
}
+33
View File
@@ -660,6 +660,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
"workspace.referral.copyLink": "نسخ الرابط",
"workspace.referral.copied": "تم النسخ",
"workspace.referral.overview.title": "ادعُ أصدقاءك",
"workspace.referral.overview.subtitle": "احصل على $5 عند اشتراك صديق. وسيحصل هو أيضًا على $5.",
"workspace.referral.instructions.share": "شارك رابط الإحالة الخاص بك",
"workspace.referral.instructions.subscribe": "ينضم صديقك ويشترك في Go",
"workspace.referral.instructions.claim": "تحصلان كلاكما على رصيد استخدام بقيمة $5 لتطبيقه على حدود استخدام Go",
"workspace.referral.rewards.title": "مكافآت الإحالة",
"workspace.referral.rewards.description": "طبّق أرصدة الإحالة المتاحة على استخدامك لـ Go.",
"workspace.referral.rewards.subtitle": "تم تطبيق {{applied}} / {{total}} من المكافآت.",
"workspace.referral.rewards.empty": "لا توجد مكافآت إحالة بعد.",
"workspace.referral.table.reward": "المكافأة",
"workspace.referral.table.referral": "الوصف",
"workspace.referral.table.date": "التاريخ",
"workspace.referral.reward.description.inviter": "تمت دعوة {{email}}",
"workspace.referral.reward.description.invitee": "تمت دعوتك بواسطة {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "اشترك لإلغاء القفل",
"workspace.referral.reward.action.view": "عرض المكافأة",
"workspace.referral.reward.action.applied": "تم تطبيق المكافأة",
"workspace.referral.reward.source.pendingInviter": "بانتظار اشتراكه",
"workspace.referral.reward.source.pendingInvitee": "اشترك لإلغاء قفل المكافأة",
"workspace.referral.reward.source.available": "المكافأة جاهزة للتطبيق",
"workspace.referral.reward.source.applied": "تم تطبيق المكافأة",
"workspace.referral.reward.status.applied": "تم تطبيق المكافأة",
"workspace.referral.reward.status.pendingInviter": "اشترك لإلغاء القفل",
"workspace.referral.reward.status.pendingInvitee": "اشترك لإلغاء القفل",
"workspace.referral.apply.noGo": "اشترك لإلغاء القفل",
"workspace.referral.apply.preview": "عرض المكافأة",
"workspace.referral.apply.action": "تطبيق",
"workspace.referral.apply.confirmTitle": "تطبيق المكافأة",
"workspace.referral.apply.confirmBody": "طبِّق {{amount}} لتقليل الاستخدام الحالي في مساحة العمل هذه.",
"workspace.referral.apply.confirmAction": "تطبيق",
"download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
"download.hero.title": "تنزيل OpenCode",
+34
View File
@@ -670,6 +670,40 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
"workspace.referral.copyLink": "Copiar link",
"workspace.referral.copied": "Copiado",
"workspace.referral.overview.title": "Convide amigos",
"workspace.referral.overview.subtitle": "Ganhe $5 quando um amigo assinar. Ele também ganha $5.",
"workspace.referral.instructions.share": "Compartilhe seu link de indicação",
"workspace.referral.instructions.subscribe": "Seu amigo entra e assina o Go",
"workspace.referral.instructions.claim":
"Vocês dois ganham um crédito de uso de $5 para aplicar aos seus limites de uso do Go",
"workspace.referral.rewards.title": "Recompensas de indicação",
"workspace.referral.rewards.description": "Aplique os créditos de indicação disponíveis no seu uso do Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
"workspace.referral.rewards.empty": "Ainda não há recompensas de indicação.",
"workspace.referral.table.reward": "Recompensa",
"workspace.referral.table.referral": "Descrição",
"workspace.referral.table.date": "Data",
"workspace.referral.reward.description.inviter": "Convidou {{email}}",
"workspace.referral.reward.description.invitee": "Convidado por {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Assine para desbloquear",
"workspace.referral.reward.action.view": "Ver recompensa",
"workspace.referral.reward.action.applied": "Recompensa aplicada",
"workspace.referral.reward.source.pendingInviter": "Aguardando ele assinar",
"workspace.referral.reward.source.pendingInvitee": "Assine para desbloquear a recompensa",
"workspace.referral.reward.source.available": "Recompensa pronta para usar",
"workspace.referral.reward.source.applied": "Recompensa aplicada",
"workspace.referral.reward.status.applied": "Recompensa aplicada",
"workspace.referral.reward.status.pendingInviter": "Assine para desbloquear",
"workspace.referral.reward.status.pendingInvitee": "Assine para desbloquear",
"workspace.referral.apply.noGo": "Assine para desbloquear",
"workspace.referral.apply.preview": "Ver recompensa",
"workspace.referral.apply.action": "Aplicar",
"workspace.referral.apply.confirmTitle": "Aplicar recompensa",
"workspace.referral.apply.confirmBody": "Aplique {{amount}} para reduzir o uso atual deste workspace.",
"workspace.referral.apply.confirmAction": "Aplicar",
"download.title": "OpenCode | Baixar",
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
"download.hero.title": "Baixar OpenCode",
+33
View File
@@ -666,6 +666,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
"workspace.referral.copyLink": "Kopiér link",
"workspace.referral.copied": "Kopieret",
"workspace.referral.overview.title": "Inviter venner",
"workspace.referral.overview.subtitle": "Få $5, når en ven abonnerer. De får også $5.",
"workspace.referral.instructions.share": "Del dit henvisningslink",
"workspace.referral.instructions.subscribe": "Din ven tilmelder sig og abonnerer på Go",
"workspace.referral.instructions.claim": "I får begge $5 i forbrugskredit til at bruge på jeres Go-forbrugsgrænser",
"workspace.referral.rewards.title": "Henvisningsbelønninger",
"workspace.referral.rewards.description": "Brug tilgængelige henvisningskreditter på dit Go-forbrug.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brugt.",
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger endnu.",
"workspace.referral.table.reward": "Belønning",
"workspace.referral.table.referral": "Beskrivelse",
"workspace.referral.table.date": "Dato",
"workspace.referral.reward.description.inviter": "Inviterede {{email}}",
"workspace.referral.reward.description.invitee": "Inviteret af {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonner for at låse op",
"workspace.referral.reward.action.view": "Vis belønning",
"workspace.referral.reward.action.applied": "Belønning brugt",
"workspace.referral.reward.source.pendingInviter": "Venter på, at de abonnerer",
"workspace.referral.reward.source.pendingInvitee": "Abonner for at låse belønningen op",
"workspace.referral.reward.source.available": "Belønning klar til brug",
"workspace.referral.reward.source.applied": "Belønning brugt",
"workspace.referral.reward.status.applied": "Belønning brugt",
"workspace.referral.reward.status.pendingInviter": "Abonner for at låse op",
"workspace.referral.reward.status.pendingInvitee": "Abonner for at låse op",
"workspace.referral.apply.noGo": "Abonner for at låse op",
"workspace.referral.apply.preview": "Vis belønning",
"workspace.referral.apply.action": "Brug",
"workspace.referral.apply.confirmTitle": "Brug belønning",
"workspace.referral.apply.confirmBody": "Brug {{amount}} til at reducere dette workspaces nuværende forbrug.",
"workspace.referral.apply.confirmAction": "Brug",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
"download.hero.title": "Download OpenCode",
+35
View File
@@ -669,6 +669,41 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
"workspace.referral.copyLink": "Link kopieren",
"workspace.referral.copied": "Kopiert",
"workspace.referral.overview.title": "Freunde einladen",
"workspace.referral.overview.subtitle": "Erhalte $5, wenn ein Freund abonniert. Er bekommt ebenfalls $5.",
"workspace.referral.instructions.share": "Teile deinen Empfehlungslink",
"workspace.referral.instructions.subscribe": "Dein Freund tritt bei und abonniert Go",
"workspace.referral.instructions.claim":
"Ihr erhaltet beide ein Nutzungsguthaben von $5, das ihr auf eure Go-Nutzungslimits anrechnen könnt",
"workspace.referral.rewards.title": "Empfehlungsbelohnungen",
"workspace.referral.rewards.description": "Verfügbare Empfehlungsguthaben auf deine Go-Nutzung anwenden.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} Belohnungen eingelöst.",
"workspace.referral.rewards.empty": "Noch keine Empfehlungsbelohnungen.",
"workspace.referral.table.reward": "Belohnung",
"workspace.referral.table.referral": "Beschreibung",
"workspace.referral.table.date": "Datum",
"workspace.referral.reward.description.inviter": "{{email}} eingeladen",
"workspace.referral.reward.description.invitee": "Eingeladen von {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonnieren zum Freischalten",
"workspace.referral.reward.action.view": "Belohnung ansehen",
"workspace.referral.reward.action.applied": "Belohnung eingelöst",
"workspace.referral.reward.source.pendingInviter": "Warten auf das Abo des Freundes",
"workspace.referral.reward.source.pendingInvitee": "Abonnieren, um Belohnung freizuschalten",
"workspace.referral.reward.source.available": "Belohnung kann eingelöst werden",
"workspace.referral.reward.source.applied": "Belohnung eingelöst",
"workspace.referral.reward.status.applied": "Belohnung eingelöst",
"workspace.referral.reward.status.pendingInviter": "Abonnieren zum Freischalten",
"workspace.referral.reward.status.pendingInvitee": "Abonnieren zum Freischalten",
"workspace.referral.apply.noGo": "Abonnieren zum Freischalten",
"workspace.referral.apply.preview": "Belohnung ansehen",
"workspace.referral.apply.action": "Einlösen",
"workspace.referral.apply.confirmTitle": "Belohnung einlösen",
"workspace.referral.apply.confirmBody":
"Löse {{amount}} ein, um die aktuelle Nutzung dieses Workspace zu reduzieren.",
"workspace.referral.apply.confirmAction": "Einlösen",
"download.title": "OpenCode | Download",
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
"download.hero.title": "OpenCode herunterladen",
+33
View File
@@ -662,6 +662,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method",
"workspace.referral.copyLink": "Copy Link",
"workspace.referral.copied": "Copied",
"workspace.referral.overview.title": "Invite friends",
"workspace.referral.overview.subtitle": "Earn $5 when a friend subscribes. Theyll get $5 too.",
"workspace.referral.instructions.share": "Share your referral link",
"workspace.referral.instructions.subscribe": "Your friend joins and subscribes to Go",
"workspace.referral.instructions.claim": "You both get a $5 usage credit to apply toward your Go usage limits",
"workspace.referral.rewards.title": "Referral rewards",
"workspace.referral.rewards.description": "Apply available referral credits toward your Go usage.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} rewards applied.",
"workspace.referral.rewards.empty": "No referral rewards yet.",
"workspace.referral.table.reward": "Reward",
"workspace.referral.table.referral": "Description",
"workspace.referral.table.date": "Date",
"workspace.referral.reward.description.inviter": "Invited {{email}}",
"workspace.referral.reward.description.invitee": "Invited by {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Subscribe to unlock",
"workspace.referral.reward.action.view": "View Reward",
"workspace.referral.reward.action.applied": "Reward Applied",
"workspace.referral.reward.source.pendingInviter": "Waiting for them to subscribe",
"workspace.referral.reward.source.pendingInvitee": "Subscribe to unlock reward",
"workspace.referral.reward.source.available": "Reward ready to apply",
"workspace.referral.reward.source.applied": "Reward applied",
"workspace.referral.reward.status.applied": "Reward Applied",
"workspace.referral.reward.status.pendingInviter": "Subscribe to unlock",
"workspace.referral.reward.status.pendingInvitee": "Subscribe to unlock",
"workspace.referral.apply.noGo": "Subscribe to unlock",
"workspace.referral.apply.preview": "View Reward",
"workspace.referral.apply.action": "Apply",
"workspace.referral.apply.confirmTitle": "Apply reward",
"workspace.referral.apply.confirmBody": "Apply {{amount}} to reduce this workspace's current usage.",
"workspace.referral.apply.confirmAction": "Apply",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
"download.hero.title": "Download OpenCode",
+34
View File
@@ -670,6 +670,40 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
"workspace.referral.copyLink": "Copiar enlace",
"workspace.referral.copied": "Copiado",
"workspace.referral.overview.title": "Invita amigos",
"workspace.referral.overview.subtitle": "Gana $5 cuando un amigo se suscriba. Él también recibirá $5.",
"workspace.referral.instructions.share": "Comparte tu enlace de referido",
"workspace.referral.instructions.subscribe": "Tu amigo se une y se suscribe a Go",
"workspace.referral.instructions.claim":
"Ambos reciben un crédito de uso de $5 para aplicar a sus límites de uso de Go",
"workspace.referral.rewards.title": "Recompensas por referidos",
"workspace.referral.rewards.description": "Aplica los créditos por referidos disponibles a tu uso de Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} recompensas aplicadas.",
"workspace.referral.rewards.empty": "Aún no hay recompensas por referidos.",
"workspace.referral.table.reward": "Recompensa",
"workspace.referral.table.referral": "Descripción",
"workspace.referral.table.date": "Fecha",
"workspace.referral.reward.description.inviter": "Invitaste a {{email}}",
"workspace.referral.reward.description.invitee": "Invitado por {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Suscríbete para desbloquear",
"workspace.referral.reward.action.view": "Ver recompensa",
"workspace.referral.reward.action.applied": "Recompensa aplicada",
"workspace.referral.reward.source.pendingInviter": "Esperando a que se suscriba",
"workspace.referral.reward.source.pendingInvitee": "Suscríbete para desbloquear la recompensa",
"workspace.referral.reward.source.available": "Recompensa lista para aplicar",
"workspace.referral.reward.source.applied": "Recompensa aplicada",
"workspace.referral.reward.status.applied": "Recompensa aplicada",
"workspace.referral.reward.status.pendingInviter": "Suscríbete para desbloquear",
"workspace.referral.reward.status.pendingInvitee": "Suscríbete para desbloquear",
"workspace.referral.apply.noGo": "Suscríbete para desbloquear",
"workspace.referral.apply.preview": "Ver recompensa",
"workspace.referral.apply.action": "Aplicar",
"workspace.referral.apply.confirmTitle": "Aplicar recompensa",
"workspace.referral.apply.confirmBody": "Aplica {{amount}} para reducir el uso actual de este workspace.",
"workspace.referral.apply.confirmAction": "Aplicar",
"download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
"download.hero.title": "Descargar OpenCode",
+35
View File
@@ -676,6 +676,41 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
"workspace.referral.copyLink": "Copier le lien",
"workspace.referral.copied": "Copié",
"workspace.referral.overview.title": "Inviter des amis",
"workspace.referral.overview.subtitle": "Gagnez $5 lorsqu'un ami s'abonne. Il recevra également $5.",
"workspace.referral.instructions.share": "Partagez votre lien de parrainage",
"workspace.referral.instructions.subscribe": "Votre ami rejoint et s'abonne à Go",
"workspace.referral.instructions.claim":
"Vous recevez tous les deux un crédit d'utilisation de $5 à appliquer à vos limites d'utilisation Go",
"workspace.referral.rewards.title": "Récompenses de parrainage",
"workspace.referral.rewards.description":
"Utilisez les crédits de parrainage disponibles pour votre utilisation de Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} récompenses utilisées.",
"workspace.referral.rewards.empty": "Aucune récompense de parrainage pour l'instant.",
"workspace.referral.table.reward": "Récompense",
"workspace.referral.table.referral": "Description",
"workspace.referral.table.date": "Date",
"workspace.referral.reward.description.inviter": "Vous avez invité {{email}}",
"workspace.referral.reward.description.invitee": "Invité par {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonnez-vous pour débloquer",
"workspace.referral.reward.action.view": "Voir la récompense",
"workspace.referral.reward.action.applied": "Récompense utilisée",
"workspace.referral.reward.source.pendingInviter": "En attente de son abonnement",
"workspace.referral.reward.source.pendingInvitee": "Abonnez-vous pour débloquer la récompense",
"workspace.referral.reward.source.available": "Récompense prête à utiliser",
"workspace.referral.reward.source.applied": "Récompense utilisée",
"workspace.referral.reward.status.applied": "Récompense utilisée",
"workspace.referral.reward.status.pendingInviter": "Abonnez-vous pour débloquer",
"workspace.referral.reward.status.pendingInvitee": "Abonnez-vous pour débloquer",
"workspace.referral.apply.noGo": "Abonnez-vous pour débloquer",
"workspace.referral.apply.preview": "Voir la récompense",
"workspace.referral.apply.action": "Utiliser",
"workspace.referral.apply.confirmTitle": "Utiliser la récompense",
"workspace.referral.apply.confirmBody": "Utilisez {{amount}} pour réduire l'utilisation actuelle de ce workspace.",
"workspace.referral.apply.confirmAction": "Utiliser",
"download.title": "OpenCode | Téléchargement",
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
"download.hero.title": "Télécharger OpenCode",
+2
View File
@@ -11,6 +11,7 @@ import { dict as da } from "~/i18n/da"
import { dict as ja } from "~/i18n/ja"
import { dict as pl } from "~/i18n/pl"
import { dict as ru } from "~/i18n/ru"
import { dict as uk } from "~/i18n/uk"
import { dict as ar } from "~/i18n/ar"
import { dict as no } from "~/i18n/no"
import { dict as br } from "~/i18n/br"
@@ -35,6 +36,7 @@ export function i18n(locale: Locale): Dict {
if (locale === "ja") return { ...base, ...ja }
if (locale === "pl") return { ...base, ...pl }
if (locale === "ru") return { ...base, ...ru }
if (locale === "uk") return { ...base, ...uk }
if (locale === "ar") return { ...base, ...ar }
if (locale === "no") return { ...base, ...no }
if (locale === "br") return { ...base, ...br }
+34
View File
@@ -668,6 +668,40 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
"workspace.referral.copyLink": "Copia link",
"workspace.referral.copied": "Copiato",
"workspace.referral.overview.title": "Invita amici",
"workspace.referral.overview.subtitle": "Guadagna $5 quando un amico si abbona. Anche lui riceverà $5.",
"workspace.referral.instructions.share": "Condividi il tuo link di referral",
"workspace.referral.instructions.subscribe": "Il tuo amico si iscrive e si abbona a Go",
"workspace.referral.instructions.claim":
"Entrambi ricevete un credito di utilizzo di $5 da applicare ai vostri limiti di utilizzo Go",
"workspace.referral.rewards.title": "Premi referral",
"workspace.referral.rewards.description": "Applica i crediti referral disponibili al tuo utilizzo di Go.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} premi utilizzati.",
"workspace.referral.rewards.empty": "Nessun premio referral ancora.",
"workspace.referral.table.reward": "Premio",
"workspace.referral.table.referral": "Descrizione",
"workspace.referral.table.date": "Data",
"workspace.referral.reward.description.inviter": "Hai invitato {{email}}",
"workspace.referral.reward.description.invitee": "Invitato da {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abbonati per sbloccare",
"workspace.referral.reward.action.view": "Vedi premio",
"workspace.referral.reward.action.applied": "Premio utilizzato",
"workspace.referral.reward.source.pendingInviter": "In attesa che si abboni",
"workspace.referral.reward.source.pendingInvitee": "Abbonati per sbloccare il premio",
"workspace.referral.reward.source.available": "Premio pronto da utilizzare",
"workspace.referral.reward.source.applied": "Premio utilizzato",
"workspace.referral.reward.status.applied": "Premio utilizzato",
"workspace.referral.reward.status.pendingInviter": "Abbonati per sbloccare",
"workspace.referral.reward.status.pendingInvitee": "Abbonati per sbloccare",
"workspace.referral.apply.noGo": "Abbonati per sbloccare",
"workspace.referral.apply.preview": "Vedi premio",
"workspace.referral.apply.action": "Utilizza",
"workspace.referral.apply.confirmTitle": "Utilizza premio",
"workspace.referral.apply.confirmBody": "Utilizza {{amount}} per ridurre l'utilizzo attuale di questo workspace.",
"workspace.referral.apply.confirmAction": "Utilizza",
"download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
"download.hero.title": "Scarica OpenCode",
+33
View File
@@ -668,6 +668,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "その他の支払い方法",
"workspace.lite.promo.selectMethod": "支払い方法を選択",
"workspace.referral.copyLink": "リンクをコピー",
"workspace.referral.copied": "コピーしました",
"workspace.referral.overview.title": "友達を招待",
"workspace.referral.overview.subtitle": "友達がサブスクライブすると $5 を獲得。友達にも $5 が付与されます。",
"workspace.referral.instructions.share": "リファラルリンクをシェア",
"workspace.referral.instructions.subscribe": "友達が参加して Go にサブスクライブ",
"workspace.referral.instructions.claim": "二人とも $5 の利用クレジットを獲得し、Go の利用上限に充当できます",
"workspace.referral.rewards.title": "リファラル特典",
"workspace.referral.rewards.description": "利用可能なリファラルクレジットを Go の利用に適用します。",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} 件の特典を適用済み。",
"workspace.referral.rewards.empty": "リファラル特典はまだありません。",
"workspace.referral.table.reward": "特典",
"workspace.referral.table.referral": "説明",
"workspace.referral.table.date": "日付",
"workspace.referral.reward.description.inviter": "{{email}} を招待しました",
"workspace.referral.reward.description.invitee": "{{email}} に招待されました",
"workspace.referral.reward.action.subscribeUnlock": "サブスクライブしてアンロック",
"workspace.referral.reward.action.view": "特典を表示",
"workspace.referral.reward.action.applied": "特典を適用済み",
"workspace.referral.reward.source.pendingInviter": "友達のサブスクライブ待ち",
"workspace.referral.reward.source.pendingInvitee": "サブスクライブして特典をアンロック",
"workspace.referral.reward.source.available": "特典は適用可能です",
"workspace.referral.reward.source.applied": "特典を適用済み",
"workspace.referral.reward.status.applied": "特典を適用済み",
"workspace.referral.reward.status.pendingInviter": "サブスクライブしてアンロック",
"workspace.referral.reward.status.pendingInvitee": "サブスクライブしてアンロック",
"workspace.referral.apply.noGo": "サブスクライブしてアンロック",
"workspace.referral.apply.preview": "特典を表示",
"workspace.referral.apply.action": "適用",
"workspace.referral.apply.confirmTitle": "特典を適用",
"workspace.referral.apply.confirmBody": "{{amount}} を適用して、このワークスペースの現在の使用量を減らします。",
"workspace.referral.apply.confirmAction": "適用",
"download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
"download.hero.title": "OpenCode をダウンロード",
+33
View File
@@ -660,6 +660,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "기타 결제 수단",
"workspace.lite.promo.selectMethod": "결제 수단 선택",
"workspace.referral.copyLink": "링크 복사",
"workspace.referral.copied": "복사됨",
"workspace.referral.overview.title": "친구 초대",
"workspace.referral.overview.subtitle": "친구가 구독하면 $5를 받으세요. 친구도 $5를 받습니다.",
"workspace.referral.instructions.share": "추천 링크 공유",
"workspace.referral.instructions.subscribe": "친구가 가입하고 Go를 구독",
"workspace.referral.instructions.claim": "두 분 모두 $5 사용 크레딧을 받아 Go 사용 한도에 적용할 수 있습니다",
"workspace.referral.rewards.title": "추천 보상",
"workspace.referral.rewards.description": "사용 가능한 추천 크레딧을 Go 사용량에 적용합니다.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}}개 보상 사용됨.",
"workspace.referral.rewards.empty": "아직 추천 보상이 없습니다.",
"workspace.referral.table.reward": "보상",
"workspace.referral.table.referral": "설명",
"workspace.referral.table.date": "날짜",
"workspace.referral.reward.description.inviter": "{{email}} 초대됨",
"workspace.referral.reward.description.invitee": "{{email}}님이 초대",
"workspace.referral.reward.action.subscribeUnlock": "구독하여 잠금 해제",
"workspace.referral.reward.action.view": "보상 보기",
"workspace.referral.reward.action.applied": "보상 사용됨",
"workspace.referral.reward.source.pendingInviter": "친구의 구독을 기다리는 중",
"workspace.referral.reward.source.pendingInvitee": "구독하여 보상 잠금 해제",
"workspace.referral.reward.source.available": "보상 사용 가능",
"workspace.referral.reward.source.applied": "보상 사용됨",
"workspace.referral.reward.status.applied": "보상 사용됨",
"workspace.referral.reward.status.pendingInviter": "구독하여 잠금 해제",
"workspace.referral.reward.status.pendingInvitee": "구독하여 잠금 해제",
"workspace.referral.apply.noGo": "구독하여 잠금 해제",
"workspace.referral.apply.preview": "보상 보기",
"workspace.referral.apply.action": "사용",
"workspace.referral.apply.confirmTitle": "보상 사용",
"workspace.referral.apply.confirmBody": "{{amount}}를 사용하여 이 워크스페이스의 현재 사용량을 줄입니다.",
"workspace.referral.apply.confirmAction": "사용",
"download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
"download.hero.title": "OpenCode 다운로드",
+33
View File
@@ -667,6 +667,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
"workspace.referral.copyLink": "Kopier lenke",
"workspace.referral.copied": "Kopiert",
"workspace.referral.overview.title": "Inviter venner",
"workspace.referral.overview.subtitle": "Få $5 når en venn abonnerer. De får også $5.",
"workspace.referral.instructions.share": "Del henvisningslenken din",
"workspace.referral.instructions.subscribe": "Vennen din blir med og abonnerer på Go",
"workspace.referral.instructions.claim": "Dere får begge $5 i brukskreditt å bruke på Go-bruksgrensene deres",
"workspace.referral.rewards.title": "Henvisningsbelønninger",
"workspace.referral.rewards.description": "Bruk tilgjengelige henvisningskreditter på Go-bruken din.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} belønninger brukt.",
"workspace.referral.rewards.empty": "Ingen henvisningsbelønninger ennå.",
"workspace.referral.table.reward": "Belønning",
"workspace.referral.table.referral": "Beskrivelse",
"workspace.referral.table.date": "Dato",
"workspace.referral.reward.description.inviter": "Inviterte {{email}}",
"workspace.referral.reward.description.invitee": "Invitert av {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Abonner for å låse opp",
"workspace.referral.reward.action.view": "Vis belønning",
"workspace.referral.reward.action.applied": "Belønning brukt",
"workspace.referral.reward.source.pendingInviter": "Venter på at de abonnerer",
"workspace.referral.reward.source.pendingInvitee": "Abonner for å låse opp belønningen",
"workspace.referral.reward.source.available": "Belønning klar til bruk",
"workspace.referral.reward.source.applied": "Belønning brukt",
"workspace.referral.reward.status.applied": "Belønning brukt",
"workspace.referral.reward.status.pendingInviter": "Abonner for å låse opp",
"workspace.referral.reward.status.pendingInvitee": "Abonner for å låse opp",
"workspace.referral.apply.noGo": "Abonner for å låse opp",
"workspace.referral.apply.preview": "Vis belønning",
"workspace.referral.apply.action": "Bruk",
"workspace.referral.apply.confirmTitle": "Bruk belønning",
"workspace.referral.apply.confirmBody": "Bruk {{amount}} for å redusere dette workspacets nåværende forbruk.",
"workspace.referral.apply.confirmAction": "Bruk",
"download.title": "OpenCode | Last ned",
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
"download.hero.title": "Last ned OpenCode",
+33
View File
@@ -668,6 +668,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Inne metody płatności",
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
"workspace.referral.copyLink": "Kopiuj link",
"workspace.referral.copied": "Skopiowano",
"workspace.referral.overview.title": "Zaproś znajomych",
"workspace.referral.overview.subtitle": "Zdobądź $5, gdy znajomy się zasubskrybuje. On też dostanie $5.",
"workspace.referral.instructions.share": "Udostępnij swój link polecający",
"workspace.referral.instructions.subscribe": "Twój znajomy dołącza i subskrybuje Go",
"workspace.referral.instructions.claim": "Oboje otrzymujecie kredyt $5 do wykorzystania na limity użycia Go",
"workspace.referral.rewards.title": "Nagrody za polecenia",
"workspace.referral.rewards.description": "Wykorzystaj dostępne środki za polecenia na swoje użycie Go.",
"workspace.referral.rewards.subtitle": "Wykorzystano {{applied}} / {{total}} nagród.",
"workspace.referral.rewards.empty": "Brak nagród za polecenia.",
"workspace.referral.table.reward": "Nagroda",
"workspace.referral.table.referral": "Opis",
"workspace.referral.table.date": "Data",
"workspace.referral.reward.description.inviter": "Zaproszono {{email}}",
"workspace.referral.reward.description.invitee": "Zaproszony przez {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Subskrybuj, aby odblokować",
"workspace.referral.reward.action.view": "Zobacz nagrodę",
"workspace.referral.reward.action.applied": "Nagroda wykorzystana",
"workspace.referral.reward.source.pendingInviter": "Oczekiwanie na jego subskrypcję",
"workspace.referral.reward.source.pendingInvitee": "Subskrybuj, aby odblokować nagrodę",
"workspace.referral.reward.source.available": "Nagroda gotowa do wykorzystania",
"workspace.referral.reward.source.applied": "Nagroda wykorzystana",
"workspace.referral.reward.status.applied": "Nagroda wykorzystana",
"workspace.referral.reward.status.pendingInviter": "Subskrybuj, aby odblokować",
"workspace.referral.reward.status.pendingInvitee": "Subskrybuj, aby odblokować",
"workspace.referral.apply.noGo": "Subskrybuj, aby odblokować",
"workspace.referral.apply.preview": "Zobacz nagrodę",
"workspace.referral.apply.action": "Wykorzystaj",
"workspace.referral.apply.confirmTitle": "Wykorzystaj nagrodę",
"workspace.referral.apply.confirmBody": "Wykorzystaj {{amount}}, aby zmniejszyć aktualne użycie w tym workspace.",
"workspace.referral.apply.confirmAction": "Wykorzystaj",
"download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
"download.hero.title": "Pobierz OpenCode",
+35
View File
@@ -674,6 +674,41 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
"workspace.referral.copyLink": "Копировать ссылку",
"workspace.referral.copied": "Скопировано",
"workspace.referral.overview.title": "Пригласите друзей",
"workspace.referral.overview.subtitle": "Получите $5, когда друг оформит подписку. Он тоже получит $5.",
"workspace.referral.instructions.share": "Поделитесь своей реферальной ссылкой",
"workspace.referral.instructions.subscribe": "Ваш друг присоединяется и оформляет подписку на Go",
"workspace.referral.instructions.claim":
"Вы оба получаете кредит на использование $5, который можно применить к лимитам использования Go",
"workspace.referral.rewards.title": "Реферальные награды",
"workspace.referral.rewards.description": "Используйте доступные реферальные кредиты для оплаты использования Go.",
"workspace.referral.rewards.subtitle": "Использовано {{applied}} / {{total}} наград.",
"workspace.referral.rewards.empty": "Реферальных наград пока нет.",
"workspace.referral.table.reward": "Награда",
"workspace.referral.table.referral": "Описание",
"workspace.referral.table.date": "Дата",
"workspace.referral.reward.description.inviter": "Приглашён {{email}}",
"workspace.referral.reward.description.invitee": "Приглашены пользователем {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "Оформите подписку для разблокировки",
"workspace.referral.reward.action.view": "Посмотреть награду",
"workspace.referral.reward.action.applied": "Награда использована",
"workspace.referral.reward.source.pendingInviter": "Ожидание его подписки",
"workspace.referral.reward.source.pendingInvitee": "Подпишитесь, чтобы разблокировать награду",
"workspace.referral.reward.source.available": "Награда готова к применению",
"workspace.referral.reward.source.applied": "Награда использована",
"workspace.referral.reward.status.applied": "Награда использована",
"workspace.referral.reward.status.pendingInviter": "Оформите подписку для разблокировки",
"workspace.referral.reward.status.pendingInvitee": "Оформите подписку для разблокировки",
"workspace.referral.apply.noGo": "Оформите подписку для разблокировки",
"workspace.referral.apply.preview": "Посмотреть награду",
"workspace.referral.apply.action": "Применить",
"workspace.referral.apply.confirmTitle": "Применить награду",
"workspace.referral.apply.confirmBody":
"Используйте {{amount}}, чтобы уменьшить текущее использование этого workspace.",
"workspace.referral.apply.confirmAction": "Применить",
"download.title": "OpenCode | Скачать",
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
"download.hero.title": "Скачать OpenCode",
+33
View File
@@ -663,6 +663,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
"workspace.referral.copyLink": "คัดลอกลิงก์",
"workspace.referral.copied": "คัดลอกแล้ว",
"workspace.referral.overview.title": "ชวนเพื่อน",
"workspace.referral.overview.subtitle": "รับ $5 เมื่อเพื่อนสมัครสมาชิก เพื่อนก็จะได้รับ $5 เช่นกัน",
"workspace.referral.instructions.share": "แชร์ลิงก์แนะนำของคุณ",
"workspace.referral.instructions.subscribe": "เพื่อนของคุณเข้าร่วมและสมัครสมาชิก Go",
"workspace.referral.instructions.claim": "คุณทั้งคู่จะได้รับเครดิตการใช้งาน $5 เพื่อใช้กับขีดจำกัดการใช้งาน Go",
"workspace.referral.rewards.title": "รางวัลการแนะนำ",
"workspace.referral.rewards.description": "ใช้เครดิตการแนะนำที่มีอยู่กับการใช้งาน Go ของคุณ",
"workspace.referral.rewards.subtitle": "ใช้แล้ว {{applied}} / {{total}} รางวัล",
"workspace.referral.rewards.empty": "ยังไม่มีรางวัลการแนะนำ",
"workspace.referral.table.reward": "รางวัล",
"workspace.referral.table.referral": "คำอธิบาย",
"workspace.referral.table.date": "วันที่",
"workspace.referral.reward.description.inviter": "เชิญ {{email}}",
"workspace.referral.reward.description.invitee": "ได้รับเชิญจาก {{email}}",
"workspace.referral.reward.action.subscribeUnlock": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.reward.action.view": "ดูรางวัล",
"workspace.referral.reward.action.applied": "ใช้รางวัลแล้ว",
"workspace.referral.reward.source.pendingInviter": "รอเพื่อนสมัครสมาชิก",
"workspace.referral.reward.source.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อกรางวัล",
"workspace.referral.reward.source.available": "รางวัลพร้อมใช้งาน",
"workspace.referral.reward.source.applied": "ใช้รางวัลแล้ว",
"workspace.referral.reward.status.applied": "ใช้รางวัลแล้ว",
"workspace.referral.reward.status.pendingInviter": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.reward.status.pendingInvitee": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.apply.noGo": "สมัครสมาชิกเพื่อปลดล็อก",
"workspace.referral.apply.preview": "ดูรางวัล",
"workspace.referral.apply.action": "ใช้",
"workspace.referral.apply.confirmTitle": "ใช้รางวัล",
"workspace.referral.apply.confirmBody": "ใช้ {{amount}} เพื่อลดการใช้งานปัจจุบันของ workspace นี้",
"workspace.referral.apply.confirmAction": "ใช้",
"download.title": "OpenCode | ดาวน์โหลด",
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
"download.hero.title": "ดาวน์โหลด OpenCode",
+34
View File
@@ -670,6 +670,40 @@ export const dict = {
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
"workspace.referral.copyLink": "Bağlantıyı Kopyala",
"workspace.referral.copied": "Kopyalandı",
"workspace.referral.overview.title": "Arkadaşlarını davet et",
"workspace.referral.overview.subtitle": "Bir arkadaşın abone olduğunda $5 kazan. O da $5 alacak.",
"workspace.referral.instructions.share": "Referans bağlantını paylaş",
"workspace.referral.instructions.subscribe": "Arkadaşın katılır ve Go'ya abone olur",
"workspace.referral.instructions.claim":
"İkiniz de Go kullanım limitlerinize uygulamak için $5 kullanım kredisi alırsınız",
"workspace.referral.rewards.title": "Davet ödülleri",
"workspace.referral.rewards.description": "Mevcut davet kredilerini Go kullanımınıza uygulayın.",
"workspace.referral.rewards.subtitle": "{{applied}} / {{total}} ödül kullanıldı.",
"workspace.referral.rewards.empty": "Henüz davet ödülü yok.",
"workspace.referral.table.reward": "Ödül",
"workspace.referral.table.referral": "Açıklama",
"workspace.referral.table.date": "Tarih",
"workspace.referral.reward.description.inviter": "{{email}} davet edildi",
"workspace.referral.reward.description.invitee": "{{email}} tarafından davet edildi",
"workspace.referral.reward.action.subscribeUnlock": "Kilidi açmak için abone ol",
"workspace.referral.reward.action.view": "Ödülü Görüntüle",
"workspace.referral.reward.action.applied": "Ödül Kullanıldı",
"workspace.referral.reward.source.pendingInviter": "Abone olması bekleniyor",
"workspace.referral.reward.source.pendingInvitee": "Ödülün kilidini açmak için abone ol",
"workspace.referral.reward.source.available": "Ödül kullanıma hazır",
"workspace.referral.reward.source.applied": "Ödül kullanıldı",
"workspace.referral.reward.status.applied": "Ödül Kullanıldı",
"workspace.referral.reward.status.pendingInviter": "Kilidi açmak için abone ol",
"workspace.referral.reward.status.pendingInvitee": "Kilidi açmak için abone ol",
"workspace.referral.apply.noGo": "Kilidi açmak için abone ol",
"workspace.referral.apply.preview": "Ödülü Görüntüle",
"workspace.referral.apply.action": "Kullan",
"workspace.referral.apply.confirmTitle": "Ödülü kullan",
"workspace.referral.apply.confirmBody": "Bu workspace'in mevcut kullanımını azaltmak için {{amount}} kullan.",
"workspace.referral.apply.confirmAction": "Kullan",
"download.title": "OpenCode | İndir",
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
"download.hero.title": "OpenCode'u İndir",
+785
View File
@@ -0,0 +1,785 @@
import { dict as en } from "./en"
export const dict = {
...en,
"nav.github": "GitHub",
"nav.docs": "Документація",
"nav.changelog": "Журнал змін",
"nav.discord": "Discord",
"nav.x": "X",
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Увійти",
"nav.free": "Завантажити",
"nav.home": "Головна",
"nav.openMenu": "Відкрити меню",
"nav.getStartedFree": "Почати безкоштовно",
"nav.logoAlt": "OpenCode",
"nav.context.copyLogo": "Копіювати логотип як SVG",
"nav.context.copyWordmark": "Копіювати знак як SVG",
"nav.context.brandAssets": "Бренд-матеріали",
"footer.github": "GitHub",
"footer.docs": "Документація",
"footer.changelog": "Журнал змін",
"footer.discord": "Discord",
"footer.x": "X",
"legal.brand": "Бренд",
"legal.privacy": "Конфіденційність",
"legal.terms": "Умови",
"email.title": "Дізнайтеся першими про нові продукти",
"email.subtitle": "Приєднуйтесь до списку очікування для раннього доступу.",
"email.placeholder": "Електронна адреса",
"email.subscribe": "Підписатися",
"email.success": "Майже готово! Перевірте пошту та підтвердьте адресу",
"notFound.title": "Не знайдено | opencode",
"notFound.heading": "404 — Сторінку не знайдено",
"notFound.home": "Головна",
"notFound.docs": "Документація",
"notFound.github": "GitHub",
"notFound.discord": "Discord",
"notFound.logoLightAlt": "світлий логотип opencode",
"notFound.logoDarkAlt": "темний логотип opencode",
"user.logout": "Вийти",
"auth.callback.error.codeMissing": "Не знайдено код авторизації.",
"workspace.select": "Виберіть робочий простір",
"workspace.createNew": "+ Створити новий робочий простір",
"workspace.modal.title": "Створити новий робочий простір",
"workspace.modal.placeholder": "Введіть назву робочого простору",
"common.cancel": "Скасувати",
"common.creating": "Створення...",
"common.create": "Створити",
"common.contactUs": "Зв'яжіться з нами",
"common.videoUnsupported": "Ваш браузер не підтримує відео.",
"common.figure": "Рис. {{n}}.",
"common.faq": "FAQ",
"common.learnMore": "Дізнатися більше",
"error.invalidPlan": "Недійсний план",
"error.workspaceRequired": "ID робочого простору обов'язкове",
"error.alreadySubscribed": "Цей робочий простір уже має підписку",
"error.limitRequired": "Ліміт обов'язковий.",
"error.monthlyLimitInvalid": "Встановіть дійсний місячний ліміт.",
"error.workspaceNameRequired": "Назва робочого простору обов'язкова.",
"error.nameTooLong": "Назва має містити не більше 255 символів.",
"error.emailRequired": "Електронна адреса обов'язкова",
"error.roleRequired": "Роль обов'язкова",
"error.idRequired": "ID обов'язкове",
"error.nameRequired": "Назва обов'язкова",
"error.providerRequired": "Провайдер обов'язковий",
"error.apiKeyRequired": "Ключ API обов'язковий",
"error.modelRequired": "Модель обов'язкова",
"error.reloadAmountMin": "Сума поповнення має бути щонайменше ${{amount}}",
"error.reloadTriggerMin": "Поріг балансу має бути щонайменше ${{amount}}",
"app.meta.description": "OpenCode — відкритий агент для програмування.",
"home.title": "OpenCode | Відкритий AI-агент для кодування",
"temp.title": "opencode | AI-агент для кодування, створений для термінала",
"temp.hero.title": "AI-агент для кодування, створений для термінала",
"temp.zen": "opencode zen",
"temp.getStarted": "Почати",
"temp.feature.native.title": "Рідний TUI",
"temp.feature.native.body": "Чуйний, рідний інтерфейс термінала з темами",
"temp.feature.zen.beforeLink": "A",
"temp.feature.zen.link": "добірка моделей",
"temp.feature.zen.afterLink": "від opencode",
"temp.feature.models.beforeLink": "Підтримує 75+ LLM-провайдерів через",
"temp.feature.models.afterLink": ", включаючи локальні моделі",
"temp.screenshot.caption": "OpenCode TUI з темою tokyonight",
"temp.screenshot.alt": "OpenCode TUI з темою tokyonight",
"temp.logoLightAlt": "світлий логотип opencode",
"temp.logoDarkAlt": "темний логотип opencode",
"home.banner.badge": "Нове",
"home.banner.text": "Десктопний застосунок доступний у бета-версії",
"home.banner.platforms": "на macOS, Windows та Linux",
"home.banner.downloadNow": "Завантажити зараз",
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
"home.hero.title": "Відкритий AI-агент для кодування",
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
"home.hero.subtitle.b": "включно з Claude, GPT, Gemini та іншими.",
"home.install.ariaLabel": "Параметри встановлення",
"home.what.title": "Що таке OpenCode?",
"home.what.body": "OpenCode — це відкритий агент, який допомагає писати код у терміналі, IDE або на десктопі.",
"home.what.lsp.title": "LSP увімкнено",
"home.what.lsp.body": "Автоматично завантажує потрібні LSP для LLM",
"home.what.multiSession.title": "Багатосесійність",
"home.what.multiSession.body": "Запускайте кількох агентів паралельно в одному проекті",
"home.what.shareLinks.title": "Посилання для обміну",
"home.what.shareLinks.body": "Діліться посиланням на будь-яку сесію для обговорення або налагодження",
"home.what.copilot.title": "GitHub Copilot",
"home.what.copilot.body": "Увійдіть через GitHub, щоб використовувати свій обліковий запис Copilot",
"home.what.chatgptPlus.title": "ChatGPT Plus/Pro",
"home.what.chatgptPlus.body": "Увійдіть через OpenAI, щоб використовувати ChatGPT Plus або Pro",
"home.what.anyModel.title": "Будь-яка модель",
"home.what.anyModel.body": "75+ LLM-провайдерів через Models.dev, включаючи локальні моделі",
"home.what.anyEditor.title": "Будь-який редактор",
"home.what.anyEditor.body": "Доступний як термінальний інтерфейс, десктопний застосунок та розширення IDE",
"home.what.readDocs": "Читати документацію",
"home.growth.title": "Відкритий AI-агент для кодування",
"home.growth.body":
"З понад <strong>{{stars}}</strong> зірками на GitHub, <strong>{{contributors}}</strong> учасниками та понад <strong>{{commits}}</strong> комітами, OpenCode використовують понад <strong>{{monthlyUsers}}</strong> розробників щомісяця.",
"home.growth.githubStars": "Зірки GitHub",
"home.growth.contributors": "Учасники",
"home.growth.monthlyDevs": "Розробників на місяць",
"home.privacy.title": "Створено для конфіденційності",
"home.privacy.body":
"OpenCode не зберігає ваш код або контекстні дані, тому може працювати в середовищах з чутливими даними.",
"home.privacy.learnMore": "Дізнатися більше про",
"home.privacy.link": "конфіденційність",
"home.faq.q1": "Що таке OpenCode?",
"home.faq.a1":
"OpenCode — це відкритий агент, який допомагає писати та запускати код з будь-якою AI-моделлю. Доступний як термінальний інтерфейс, десктопний застосунок або розширення IDE.",
"home.faq.q2": "Як почати користуватися OpenCode?",
"home.faq.a2.before": "Найпростіший спосіб почати — прочитати",
"home.faq.a2.link": "вступ",
"home.faq.q3": "Чи потрібні додаткові AI-підписки для використання OpenCode?",
"home.faq.a3.p1":
"Не обов'язково, OpenCode має набір безкоштовних моделей, які можна використовувати без реєстрації.",
"home.faq.a3.p2.beforeZen":
"Крім цього, ви можете використовувати будь-які популярні моделі, створивши обліковий запис",
"home.faq.a3.p2.afterZen": ".",
"home.faq.a3.p3":
"Хоча ми рекомендуємо Zen, OpenCode також працює з усіма популярними провайдерами, такими як OpenAI, Anthropic, xAI тощо.",
"home.faq.a3.p4.beforeLocal": "Ви навіть можете підключити свої",
"home.faq.a3.p4.localLink": "локальні моделі",
"home.faq.q4": "Чи можу я використовувати свої наявні AI-підписки з OpenCode?",
"home.faq.a4.p1":
"Так, OpenCode підтримує підписки всіх основних провайдерів. Ви можете використовувати Claude Pro/Max, ChatGPT Plus/Pro або GitHub Copilot.",
"home.faq.q5": "Чи можна використовувати OpenCode лише в терміналі?",
"home.faq.a5.beforeDesktop": "Вже ні! OpenCode тепер доступний як застосунок для",
"home.faq.a5.desktop": "десктопа",
"home.faq.a5.and": "та",
"home.faq.a5.web": "вебу",
"home.faq.q6": "Скільки коштує OpenCode?",
"home.faq.a6":
"OpenCode є 100% безкоштовним. Він також має набір безкоштовних моделей. Додаткові витрати можливі, якщо ви підключите іншого провайдера.",
"home.faq.q7": "А як щодо даних та конфіденційності?",
"home.faq.a7.p1":
"Ваші дані зберігаються лише тоді, коли ви використовуєте безкоштовні моделі або створюєте посилання для обміну.",
"home.faq.a7.p2.beforeModels": "Дізнайтеся більше про",
"home.faq.a7.p2.modelsLink": "наші моделі",
"home.faq.a7.p2.and": "та",
"home.faq.a7.p2.shareLink": "сторінки обміну",
"home.faq.q8": "Чи є OpenCode відкритим?",
"home.faq.a8.p1": "Так, OpenCode повністю відкритий. Вихідний код доступний публічно на",
"home.faq.a8.p2": "під ліцензією",
"home.faq.a8.mitLicense": "MIT License",
"home.faq.a8.p3":
", тобто кожен може використовувати, змінювати або сприяти його розвитку. Будь-хто зі спільноти може створювати issues, надсилати pull request'и та розширювати функціональність.",
"home.zenCta.title": "Отримайте доступ до надійних оптимізованих моделей для агентів кодування",
"home.zenCta.body":
"Zen дає доступ до добірки AI-моделей, які OpenCode протестував спеціально для агентів кодування. Не турбуйтеся про нестабільну якість — використовуйте перевірені моделі.",
"home.zenCta.link": "Дізнатися про Zen",
"zen.title": "OpenCode Zen | Добірка надійних оптимізованих моделей для агентів кодування",
"zen.hero.title": "Надійні оптимізовані моделі для агентів кодування",
"zen.hero.body":
"Zen дає доступ до добірки AI-моделей, які OpenCode протестував спеціально для агентів кодування. Не турбуйтеся про нестабільну якість — використовуйте перевірені моделі.",
"zen.faq.q1": "Що таке OpenCode Zen?",
"zen.faq.a1": "Zen — це добірка AI-моделей, протестованих для агентів кодування, створена командою OpenCode.",
"zen.faq.q2": "Чому Zen точніший?",
"zen.faq.a2":
"Zen надає лише моделі, спеціально протестовані для агентів кодування. Ви ж не використовуєте масло ніж для стейка — не використовуйте погані моделі для кодування.",
"zen.faq.q3": "Чи Zen дешевший?",
"zen.faq.a3":
"Zen не є прибутковим. Zen передає вам вартість від провайдерів моделей. Чим вище використання Zen, тим кращі ціни OpenCode може узгодити та передати вам.",
"zen.faq.q4": "Скільки коштує Zen?",
"zen.faq.a4.p1.beforePricing": "Zen",
"zen.faq.a4.p1.pricingLink": "стягує плату за запит",
"zen.faq.a4.p1.afterPricing": "без націнок — ви платите рівно стільки, скільки стягує провайдер моделі.",
"zen.faq.a4.p2.beforeAccount": "Загальна вартість залежить від використання. Ви можете встановити місячні ліміти в",
"zen.faq.a4.p2.accountLink": "обліковому записі",
"zen.faq.a4.p3":
"Щоб покрити витрати, OpenCode додає лише невелику комісію за обробку платежу в розмірі $1.23 за кожне поповнення балансу $20.",
"zen.faq.q5": "А як щодо даних та конфіденційності?",
"zen.faq.a5.beforeExceptions":
"Усі моделі Zen розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"zen.faq.a5.exceptionsLink": "такими винятками",
"zen.faq.q6": "Чи можна встановити ліміти витрат?",
"zen.faq.a6": "Так, ви можете встановити місячні ліміти витрат в обліковому записі.",
"zen.faq.q7": "Чи можна скасувати?",
"zen.faq.a7": "Так, ви можете вимкнути оплату в будь-який час і використовувати залишок.",
"zen.faq.q8": "Чи можна використовувати Zen з іншими агентами кодування?",
"zen.faq.a8":
"Хоча Zen чудово працює з OpenCode, ви можете використовувати Zen з будь-яким агентом. Дотримуйтесь інструкцій з налаштування у вашому агенті.",
"zen.cta.start": "Почати з Zen",
"zen.pricing.title": "Додати $20 балансу Pay as you go",
"zen.pricing.fee": "(+$1.23 комісія за обробку карти)",
"zen.pricing.body": "Використовуйте з будь-яким агентом. Встановлюйте місячні ліміти. Скасуйте в будь-який час.",
"zen.problem.title": "Яку проблему вирішує Zen?",
"zen.problem.body":
"Доступно багато моделей, але лише деякі добре працюють з агентами кодування. Більшість провайдерів налаштовують їх по-різному з різними результатами.",
"zen.problem.subtitle": "Ми вирішуємо це для всіх, а не лише для користувачів OpenCode.",
"zen.problem.item1": "Тестування вибраних моделей та консультації з їхніми командами",
"zen.problem.item2": "Співпраця з провайдерами для забезпечення правильної доставки",
"zen.problem.item3": "Бенчмаркінг усіх комбінацій моделей та провайдерів, які ми рекомендуємо",
"zen.how.title": "Як працює Zen",
"zen.how.body":
"Хоча ми пропонуємо використовувати Zen з OpenCode, ви можете використовувати Zen з будь-яким агентом.",
"zen.how.step1.title": "Зареєструйтеся та додайте $20 балансу",
"zen.how.step1.beforeLink": "дотримуйтесь",
"zen.how.step1.link": "інструкцій з налаштування",
"zen.how.step2.title": "Використовуйте Zen із прозорими цінами",
"zen.how.step2.link": "платіть за запит",
"zen.how.step2.afterLink": "без націнок",
"zen.how.step3.title": "Автоматичне поповнення",
"zen.how.step3.body": "коли баланс досягає $5, ми автоматично додаємо $20",
"zen.privacy.title": "Ваша конфіденційність важлива для нас",
"zen.privacy.beforeExceptions":
"Усі моделі Zen розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"zen.privacy.exceptionsLink": "такими винятками",
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"go.hero.title": "Недорогі моделі кодування для всіх",
"go.hero.body":
"Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
"go.cta.start": "Підписатися на Go",
"go.cta.template": "{{text}} {{price}}",
"go.cta.text": "Підписатися на Go",
"go.cta.price": "$10/місяць",
"go.cta.promo": "$5 перший місяць",
"go.pricing.body":
"Використовуйте з будь-яким агентом. $5 перший місяць, потім $10/місяць. Поповнюйте за потреби. Скасуйте в будь-який час.",
"go.graph.free": "Безкоштовно",
"go.graph.freePill": "Big Pickle та безкоштовні моделі",
"go.graph.go": "Go",
"go.graph.label": "Запитів за 5 годин",
"go.graph.usageLimits": "Ліміти використання",
"go.graph.tick": "{{n}}x",
"go.graph.aria": "Запитів за 5 год: {{free}} vs {{go}}",
"go.testimonials.brand.zen": "Zen",
"go.testimonials.brand.go": "Go",
"go.testimonials.handle": "@OpenCode",
"go.testimonials.dax.name": "Dax Raad",
"go.testimonials.dax.title": "ex-CEO, Terminal Products",
"go.testimonials.dax.quoteAfter": "змінило моє життя, це справді очевидний вибір.",
"go.testimonials.jay.name": "Jay V",
"go.testimonials.jay.title": "ex-Founder, SEED, PM, Melt, Pop, Dapt, Cadmus, and ViewPoint",
"go.testimonials.jay.quoteBefore": "4 з 5 людей у нашій команді люблять використовувати",
"go.testimonials.jay.quoteAfter": ".",
"go.testimonials.adam.name": "Adam Elmore",
"go.testimonials.adam.title": "ex-Hero, AWS",
"go.testimonials.adam.quoteBefore": "Я не можу достатньо рекомендувати",
"go.testimonials.adam.quoteAfter": ". Серйозно, це дійсно добре.",
"go.testimonials.david.name": "David Hill",
"go.testimonials.david.title": "ex-Head of Design, Laravel",
"go.testimonials.david.quoteBefore": "Завдяки",
"go.testimonials.david.quoteAfter": "я знаю, що всі моделі протестовані та ідеальні для агентів кодування.",
"go.testimonials.frank.name": "Frank Wang",
"go.testimonials.frank.title": "ex-Intern, Nvidia (4 times)",
"go.testimonials.frank.quote": "Хотів би я досі бути в Nvidia.",
"go.problem.title": "Яку проблему вирішує Go?",
"go.problem.body":
"Ми зосереджені на тому, щоб зробити досвід OpenCode доступним для якомога більшої кількості людей. OpenCode Go — це недорога підписка: $5 за перший місяць, потім $10/місяць. Вона надає щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.",
"go.problem.subtitle": " ",
"go.problem.item1": "Недорога підписка",
"go.problem.item2": "Щедрі ліміти та надійний доступ",
"go.problem.item3": "Створено для якомога більшої кількості програмістів",
"go.problem.item4":
"Включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash",
"go.how.title": "Як працює Go",
"go.how.body":
"Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.",
"go.how.step1.title": "Створіть обліковий запис",
"go.how.step1.beforeLink": "дотримуйтесь",
"go.how.step1.link": "інструкцій з налаштування",
"go.how.step2.title": "Підпишіться на Go",
"go.how.step2.link": "$5 перший місяць",
"go.how.step2.afterLink": "потім $10/місяць із щедрими лімітами",
"go.how.step3.title": "Почніть кодувати",
"go.how.step3.body": "з надійним доступом до моделей з відкритим кодом",
"go.privacy.title": "Ваша конфіденційність важлива для нас",
"go.privacy.body":
"План розроблений переважно для міжнародних користувачів, з моделями, розміщеними в США, ЄС та Сінгапурі для стабільного глобального доступу.",
"go.privacy.contactAfter": "якщо у вас є запитання.",
"go.privacy.beforeExceptions":
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"go.privacy.exceptionsLink": "такими винятками",
"go.faq.q1": "Що таке OpenCode Go?",
"go.faq.a1":
"Go — це недорога підписка, яка надає надійний доступ до найкращих моделей з відкритим кодом для агентного кодування.",
"go.faq.q2": "Які моделі включає Go?",
"go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.",
"go.faq.q3": "Чи Go те саме, що Zen?",
"go.faq.a3":
"Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом.",
"go.faq.q4": "Скільки коштує Go?",
"go.faq.a4.p1.beforePricing": "Go коштує",
"go.faq.a4.p1.pricingLink": "$5 за перший місяць",
"go.faq.a4.p1.afterPricing": "потім $10/місяць із щедрими лімітами.",
"go.faq.a4.p2.beforeAccount": "Ви можете керувати підпискою в",
"go.faq.a4.p2.accountLink": "обліковому записі",
"go.faq.a4.p3": "Скасуйте в будь-який час.",
"go.faq.q5": "А як щодо даних та конфіденційності?",
"go.faq.a5.body":
"План розроблений переважно для міжнародних користувачів, з моделями в США, ЄС та Сінгапурі. Провайдери дотримуються політики нульового зберігання.",
"go.faq.a5.beforeExceptions":
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
"go.faq.a5.exceptionsLink": "такими винятками",
"go.faq.q6": "Чи можна поповнити баланс?",
"go.faq.a6": "Якщо вам потрібно більше використання, ви можете поповнити баланс в обліковому записі.",
"go.faq.q7": "Чи можна скасувати?",
"go.faq.a7": "Так, ви можете скасувати в будь-який час.",
"go.faq.q8": "Чи можна використовувати Go з іншими агентами кодування?",
"go.faq.a8": "Так, ви можете використовувати Go з будь-яким агентом.",
"go.faq.q9": "Яка різниця між безкоштовними моделями та Go?",
"go.faq.a9":
"Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.",
"zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.",
"zen.api.error.modelNotSupported": "Модель {{model}} не підтримується",
"zen.api.error.modelFormatNotSupported": "Модель {{model}} не підтримується для формату {{format}}",
"zen.api.error.noProviderAvailable": "Немає доступного провайдера",
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується",
"zen.api.error.missingApiKey": "Відсутній ключ API.",
"zen.api.error.invalidApiKey": "Недійсний ключ API.",
"zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
"Досягнуто тижневого ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
"Досягнуто місячного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
"zen.api.error.noPaymentMethod": "Немає способу оплати. Додайте метод оплати: {{billingUrl}}",
"zen.api.error.insufficientBalance": "Недостатньо коштів. Керуйте оплатою: {{billingUrl}}",
"zen.api.error.workspaceMonthlyLimitReached":
"Ваш робочий простір досяг місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{billingUrl}}",
"zen.api.error.userMonthlyLimitReached":
"Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель вимкнено",
"zen.api.error.trialEnded":
"Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}",
"black.meta.title": "OpenCode Black | Доступ до найкращих моделей кодування",
"black.meta.description": "Отримайте доступ до Claude, GPT, Gemini та інших із планами підписки OpenCode Black.",
"black.hero.title": "Доступ до найкращих моделей кодування",
"black.hero.subtitle": "Включаючи Claude, GPT, Gemini та інші",
"black.title": "OpenCode Black | Ціни",
"black.paused": "Реєстрація в план Black тимчасово призупинена.",
"black.plan.icon20": "План Black 20",
"black.plan.icon100": "План Black 100",
"black.plan.icon200": "План Black 200",
"black.plan.multiplier100": "5x більше використання ніж Black 20",
"black.plan.multiplier200": "20x більше використання ніж Black 20",
"black.price.perMonth": "на місяць",
"black.price.perPersonBilledMonthly": "за особу з щомісячною оплатою",
"black.terms.1": "Ваша підписка не розпочнеться негайно",
"black.terms.2": "Вас додадуть до списку очікування та активують незабаром",
"black.terms.3": "Картку буде списано лише після активації підписки",
"black.terms.4": "Діють ліміти використання, інтенсивне автоматичне використання може швидше вичерпати ліміти",
"black.terms.5": "Підписки призначені для фізичних осіб, зверніться в Enterprise для команд",
"black.terms.6": "Ліміти можуть бути змінені, а плани можуть бути припинені в майбутньому",
"black.terms.7": "Скасуйте підписку в будь-який час",
"black.action.continue": "Продовжити",
"black.finePrint.beforeTerms": "Зазначені ціни не включають податки",
"black.finePrint.terms": "Умови надання послуг",
"black.workspace.title": "OpenCode Black | Виберіть робочий простір",
"black.workspace.selectPlan": "Виберіть робочий простір для цього плану",
"black.workspace.name": "Робочий простір {{n}}",
"black.subscribe.title": "Підписатися на OpenCode Black",
"black.subscribe.paymentMethod": "Спосіб оплати",
"black.subscribe.loadingPaymentForm": "Завантаження форми оплати...",
"black.subscribe.selectWorkspaceToContinue": "Виберіть робочий простір для продовження",
"black.subscribe.failurePrefix": "Ой!",
"black.subscribe.error.generic": "Сталася помилка",
"black.subscribe.error.invalidPlan": "Недійсний план",
"black.subscribe.error.workspaceRequired": "ID робочого простору обов'язкове",
"black.subscribe.error.alreadySubscribed": "Цей робочий простір уже має підписку",
"black.subscribe.processing": "Обробка...",
"black.subscribe.submit": "Підписатися ${{plan}}",
"black.subscribe.form.chargeNotice": "Платіж буде списано лише після активації підписки",
"black.subscribe.success.title": "Ви в списку очікування OpenCode Black",
"black.subscribe.success.subscriptionPlan": "План підписки",
"black.subscribe.success.planName": "OpenCode Black {{plan}}",
"black.subscribe.success.amount": "Сума",
"black.subscribe.success.amountValue": "${{plan}} на місяць",
"black.subscribe.success.paymentMethod": "Спосіб оплати",
"black.subscribe.success.dateJoined": "Дата приєднання",
"black.subscribe.success.chargeNotice": "Вашу картку буде списано після активації підписки",
"workspace.nav.zen": "Zen",
"workspace.nav.go": "Go",
"workspace.nav.usage": "Використання",
"workspace.nav.apiKeys": "Ключі API",
"workspace.nav.members": "Учасники",
"workspace.nav.billing": "Оплата",
"workspace.nav.settings": "Налаштування",
"workspace.home.banner.beforeLink": "Надійні оптимізовані моделі для агентів кодування.",
"workspace.lite.banner.beforeLink": "Недорогі моделі кодування для всіх.",
"workspace.home.billing.loading": "Завантаження...",
"workspace.home.billing.enable": "Увімкнути оплату",
"workspace.home.billing.currentBalance": "Поточний баланс",
"workspace.newUser.feature.tested.title": "Протестовані та перевірені моделі",
"workspace.newUser.feature.tested.body":
"Ми протестували моделі спеціально для агентів кодування, щоб забезпечити найкращу продуктивність.",
"workspace.newUser.feature.quality.title": "Найвища якість",
"workspace.newUser.feature.quality.body":
"Доступ до моделей, налаштованих для оптимальної продуктивності — без зниження якості.",
"workspace.newUser.feature.lockin.title": "Без блокування (Lock-in)",
"workspace.newUser.feature.lockin.body":
"Використовуйте Zen з будь-яким агентом і продовжуйте користуватися іншими провайдерами.",
"workspace.newUser.copyApiKey": "Копіювати ключ API",
"workspace.newUser.copyKey": "Копіювати ключ",
"workspace.newUser.copied": "Скопійовано!",
"workspace.newUser.step.enableBilling": "Увімкнути оплату",
"workspace.newUser.step.login.before": "Запустіть",
"workspace.newUser.step.login.after": "і виберіть opencode",
"workspace.newUser.step.pasteKey": "Вставте ключ API",
"workspace.newUser.step.models.before": "Запустіть opencode і виконайте",
"workspace.newUser.step.models.after": "щоб вибрати модель",
"workspace.models.title": "Моделі",
"workspace.models.subtitle.beforeLink": "Керуйте доступом учасників до моделей.",
"workspace.models.table.model": "Модель",
"workspace.models.table.enabled": "Увімкнено",
"workspace.providers.title": "Принесіть власний ключ (BYOK)",
"workspace.providers.subtitle": "Налаштуйте власні ключі API від AI-провайдерів.",
"workspace.providers.placeholder": "Введіть ключ API {{provider}} ({{prefix}}...)",
"workspace.providers.configure": "Налаштувати",
"workspace.providers.edit": "Редагувати",
"workspace.providers.delete": "Видалити",
"workspace.providers.saving": "Збереження...",
"workspace.providers.save": "Зберегти",
"workspace.providers.table.provider": "Провайдер",
"workspace.providers.table.apiKey": "Ключ API",
"workspace.usage.title": "Історія використання",
"workspace.usage.subtitle": "Останнє використання API та витрати.",
"workspace.usage.empty": "Зробіть перший API-запит, щоб почати.",
"workspace.usage.table.date": "Дата",
"workspace.usage.table.model": "Модель",
"workspace.usage.table.input": "Вхід",
"workspace.usage.table.output": "Вихід",
"workspace.usage.table.cost": "Вартість",
"workspace.usage.table.session": "Сесія",
"workspace.usage.breakdown.input": "Вхід",
"workspace.usage.breakdown.cacheRead": "Читання кешу",
"workspace.usage.breakdown.cacheWrite": "Запис кешу",
"workspace.usage.breakdown.output": "Вихід",
"workspace.usage.breakdown.reasoning": "Міркування",
"workspace.usage.subscription": "Black (${{amount}})",
"workspace.usage.lite": "Go (${{amount}})",
"workspace.usage.byok": "BYOK (${{amount}})",
"workspace.cost.title": "Вартість",
"workspace.cost.subtitle": "Витрати в розрізі моделей.",
"workspace.cost.allModels": "Усі моделі",
"workspace.cost.allKeys": "Усі ключі",
"workspace.cost.deletedSuffix": "(видалено)",
"workspace.cost.empty": "Немає даних про використання за вибраний період.",
"workspace.cost.subscriptionShort": "підп",
"workspace.keys.title": "Ключі API",
"workspace.keys.subtitle": "Керуйте ключами API для доступу до сервісів opencode.",
"workspace.keys.create": "Створити ключ API",
"workspace.keys.placeholder": "Введіть назву ключа",
"workspace.keys.empty": "Створіть ключ API шлюзу opencode",
"workspace.keys.table.name": "Назва",
"workspace.keys.table.key": "Ключ",
"workspace.keys.table.createdBy": "Створено",
"workspace.keys.table.lastUsed": "Останнє використання",
"workspace.keys.copyApiKey": "Копіювати ключ API",
"workspace.keys.delete": "Видалити",
"workspace.members.title": "Учасники",
"workspace.members.subtitle": "Керуйте учасниками робочого простору та їхніми дозволами.",
"workspace.members.invite": "Запросити учасника",
"workspace.members.inviting": "Запрошення...",
"workspace.members.beta.beforeLink": "Робочі простори безкоштовні для команд під час бета-версії.",
"workspace.members.form.invitee": "Запрошений",
"workspace.members.form.emailPlaceholder": "Введіть email",
"workspace.members.form.role": "Роль",
"workspace.members.form.monthlyLimit": "Місячний ліміт витрат",
"workspace.members.noLimit": "Без ліміту",
"workspace.members.noLimitLowercase": "без ліміту",
"workspace.members.invited": "запрошено",
"workspace.members.edit": "Редагувати",
"workspace.members.delete": "Видалити",
"workspace.members.saving": "Збереження...",
"workspace.members.save": "Зберегти",
"workspace.members.table.email": "Email",
"workspace.members.table.role": "Роль",
"workspace.members.table.monthLimit": "Ліміт на місяць",
"workspace.members.role.admin": "Адміністратор",
"workspace.members.role.adminDescription": "Може керувати моделями, учасниками та оплатою",
"workspace.members.role.member": "Учасник",
"workspace.members.role.memberDescription": "Може створювати ключі API лише для себе",
"workspace.settings.title": "Налаштування",
"workspace.settings.subtitle": "Оновіть назву робочого простору та налаштування.",
"workspace.settings.workspaceName": "Назва робочого простору",
"workspace.settings.defaultName": "Стандартна",
"workspace.settings.updating": "Оновлення...",
"workspace.settings.save": "Зберегти",
"workspace.settings.edit": "Редагувати",
"workspace.billing.title": "Оплата",
"workspace.billing.subtitle.beforeLink": "Керуйте способами оплати.",
"workspace.billing.contactUs": "Зв'яжіться з нами",
"workspace.billing.subtitle.afterLink": "якщо у вас є запитання.",
"workspace.billing.currentBalance": "Поточний баланс",
"workspace.billing.add": "Додати $",
"workspace.billing.enterAmount": "Введіть суму",
"workspace.billing.loading": "Завантаження...",
"workspace.billing.addAction": "Додати",
"workspace.billing.addBalance": "Поповнити баланс",
"workspace.billing.alipay": "Alipay",
"workspace.billing.wechat": "WeChat Pay",
"workspace.billing.linkedToStripe": "Підключено до Stripe",
"workspace.billing.manage": "Керувати",
"workspace.billing.enable": "Увімкнути оплату",
"workspace.monthlyLimit.title": "Місячний ліміт",
"workspace.monthlyLimit.subtitle": "Встановіть місячний ліміт використання для облікового запису.",
"workspace.monthlyLimit.placeholder": "50",
"workspace.monthlyLimit.setting": "Встановлення...",
"workspace.monthlyLimit.set": "Встановити",
"workspace.monthlyLimit.edit": "Редагувати ліміт",
"workspace.monthlyLimit.noLimit": "Ліміт використання не встановлено.",
"workspace.monthlyLimit.currentUsage.beforeMonth": "Поточне використання за",
"workspace.monthlyLimit.currentUsage.beforeAmount": "становить $",
"workspace.redeem.title": "Активувати купон",
"workspace.redeem.subtitle": "Активуйте код купона для отримання коштів або бонусів.",
"workspace.redeem.placeholder": "Введіть код купона",
"workspace.redeem.redeem": "Активувати",
"workspace.redeem.redeeming": "Активація...",
"workspace.redeem.success": "Купон успішно активовано.",
"workspace.reload.title": "Автоматичне поповнення",
"workspace.reload.disabled.before": "Автоматичне поповнення",
"workspace.reload.disabled.state": "вимкнено",
"workspace.reload.disabled.after": "Увімкніть для автоматичного поповнення при низькому балансі.",
"workspace.reload.enabled.before": "Автоматичне поповнення",
"workspace.reload.enabled.state": "увімкнено",
"workspace.reload.enabled.middle": "Ми поповнимо",
"workspace.reload.processingFee": "комісія за обробку",
"workspace.reload.enabled.after": "коли баланс досягне",
"workspace.reload.edit": "Редагувати",
"workspace.reload.enable": "Увімкнути",
"workspace.reload.enableAutoReload": "Увімкнути автоматичне поповнення",
"workspace.reload.reloadAmount": "Поповнити на $",
"workspace.reload.whenBalanceReaches": "Коли баланс досягне $",
"workspace.reload.saving": "Збереження...",
"workspace.reload.save": "Зберегти",
"workspace.reload.failedAt": "Поповнення не вдалося о",
"workspace.reload.reason": "Причина:",
"workspace.reload.updatePaymentMethod": "Оновіть спосіб оплати та спробуйте ще раз.",
"workspace.reload.retrying": "Повтор...",
"workspace.reload.retry": "Повторити",
"workspace.reload.error.paymentFailed": "Платіж не вдався.",
"workspace.payments.title": "Історія платежів",
"workspace.payments.subtitle": "Останні платіжні транзакції.",
"workspace.payments.table.date": "Дата",
"workspace.payments.table.paymentId": "ID платежу",
"workspace.payments.table.amount": "Сума",
"workspace.payments.table.receipt": "Квитанція",
"workspace.payments.type.credit": "кредит",
"workspace.payments.type.subscription": "підписка",
"workspace.payments.view": "Переглянути",
"workspace.black.loading": "Завантаження...",
"workspace.black.time.day": "день",
"workspace.black.time.days": "дні",
"workspace.black.time.hour": "година",
"workspace.black.time.hours": "годин(и)",
"workspace.black.time.minute": "хвилина",
"workspace.black.time.minutes": "хвилин(и)",
"workspace.black.time.fewSeconds": "кілька секунд",
"workspace.black.subscription.title": "Підписка",
"workspace.black.subscription.message": "Ви підписані на OpenCode Black за ${{plan}} на місяць.",
"workspace.black.subscription.manage": "Керувати підпискою",
"workspace.black.subscription.rollingUsage": "Використання (5 год)",
"workspace.black.subscription.weeklyUsage": "Тижневе використання",
"workspace.black.subscription.resetsIn": "Скидається через",
"workspace.black.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.black.waitlist.title": "Список очікування",
"workspace.black.waitlist.joined": "Ви в списку очікування на план OpenCode Black за ${{plan}} на місяць.",
"workspace.black.waitlist.ready": "Ми готові зареєструвати вас на план OpenCode Black за ${{plan}} на місяць.",
"workspace.black.waitlist.leave": "Залишити список очікування",
"workspace.black.waitlist.leaving": "Вихід...",
"workspace.black.waitlist.left": "Вишли",
"workspace.black.waitlist.enroll": "Зареєструватися",
"workspace.black.waitlist.enrolling": "Реєстрація...",
"workspace.black.waitlist.enrolled": "Зареєстровано",
"workspace.black.waitlist.enrollNote":
"Після натискання «Зареєструватися» підписка почнеться негайно, а картку буде списано.",
"workspace.lite.loading": "Завантаження...",
"workspace.lite.time.day": "день",
"workspace.lite.time.days": "дні",
"workspace.lite.time.hour": "година",
"workspace.lite.time.hours": "годин(и)",
"workspace.lite.time.minute": "хвилина",
"workspace.lite.time.minutes": "хвилин(и)",
"workspace.lite.time.fewSeconds": "кілька секунд",
"workspace.lite.subscription.message": "Ви підписані на OpenCode Go.",
"workspace.lite.subscription.manage": "Керувати підпискою",
"workspace.lite.subscription.rollingUsage": "Ковзне використання",
"workspace.lite.subscription.weeklyUsage": "Тижневе використання",
"workspace.lite.subscription.monthlyUsage": "Місячне використання",
"workspace.lite.subscription.resetsIn": "Скидається через",
"workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.',
"workspace.lite.black.message":
"Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.",
"workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.",
"workspace.lite.promo.description": "OpenCode Go починається від {{price}}, потім $10/місяць, із щедрими лімітами.",
"workspace.lite.promo.price": "$5 за перший місяць",
"workspace.lite.promo.modelsTitle": "Що включено",
"workspace.lite.promo.footer": "План призначений для міжнародних користувачів. Ціни можуть змінюватися.",
"workspace.lite.promo.subscribe": "Підписатися на Go",
"workspace.lite.promo.subscribing": "Перенаправлення...",
"workspace.lite.promo.otherMethods": "Інші способи оплати",
"workspace.lite.promo.selectMethod": "Виберіть спосіб оплати",
"download.title": "OpenCode | Завантажити",
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
"download.hero.title": "Завантажити OpenCode",
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
"download.hero.button": "Завантажити для {{os}}",
"download.section.terminal": "Термінал OpenCode",
"download.section.desktop": "Десктоп OpenCode (Бета)",
"download.section.extensions": "Розширення OpenCode",
"download.section.integrations": "Інтеграції OpenCode",
"download.action.download": "Завантажити",
"download.action.install": "Встановити",
"download.platform.macosAppleSilicon": "macOS (Apple Silicon)",
"download.platform.macosIntel": "macOS (Intel)",
"download.platform.windowsX64": "Windows (x64)",
"download.platform.linuxDeb": "Linux (.deb)",
"download.platform.linuxRpm": "Linux (.rpm)",
"download.faq.a3.beforeLocal":
"Не обов'язково, але ймовірно. Вам знадобиться AI-підписка, якщо ви хочете підключити платного провайдера, хоча ви можете працювати з",
"download.faq.a3.localLink": "локальними моделями",
"download.faq.a3.afterLocal.beforeZen": "безкоштовно. Хоча ми рекомендуємо",
"download.faq.a3.afterZen":
", OpenCode працює з усіма популярними провайдерами, такими як OpenAI, Anthropic, xAI тощо.",
"download.faq.a5.p1": "OpenCode є 100% безкоштовним.",
"download.faq.a5.p2.beforeZen":
"Будь-які додаткові витрати будуть з вашої підписки у провайдера моделі. Ми рекомендуємо",
"download.faq.a5.p2.afterZen": ".",
"download.faq.a6.p1": "Ваші дані зберігаються лише при створенні посилань для обміну в OpenCode.",
"download.faq.a6.p2.beforeShare": "Дізнайтеся більше про",
"download.faq.a6.shareLink": "сторінки обміну",
"enterprise.title": "OpenCode | Enterprise-рішення для вашої організації",
"enterprise.meta.description": "Зв'яжіться з OpenCode для Enterprise-рішень",
"enterprise.hero.title": "Ваш код належить вам",
"enterprise.hero.body1":
"OpenCode працює безпечно всередині вашої організації без зберігання даних, ліцензійних обмежень. Почніть пробний період із командою, потім розгорніть через SSO та внутрішній AI-шлюз.",
"enterprise.hero.body2": "Дайте знати, чим ми можемо допомогти.",
"enterprise.form.name.label": "Повне ім'я",
"enterprise.form.name.placeholder": "Джеф Безос",
"enterprise.form.role.label": "Посада",
"enterprise.form.role.placeholder": "Голова правління",
"enterprise.form.company.label": "Компанія",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Робоча електронна адреса",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Номер телефону",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Яку проблему ви намагаєтеся вирішити?",
"enterprise.form.message.placeholder": "Нам потрібна допомога з...",
"enterprise.form.send": "Надіслати",
"enterprise.form.sending": "Надсилання...",
"enterprise.form.success": "Повідомлення надіслано, ми зв'яжемося найближчим часом.",
"enterprise.form.success.submitted": "Форму успішно надіслано.",
"enterprise.form.error.allFieldsRequired": "Усі поля обов'язкові.",
"enterprise.form.error.invalidEmailFormat": "Недійсний формат email.",
"enterprise.form.error.internalServer": "Внутрішня помилка сервера.",
"enterprise.faq.title": "FAQ",
"enterprise.faq.q1": "Що таке OpenCode Enterprise?",
"enterprise.faq.a1":
"OpenCode Enterprise для організацій, які хочуть гарантувати, що код і дані ніколи не залишають їхню інфраструктуру.",
"enterprise.faq.q2": "Як почати з OpenCode Enterprise?",
"enterprise.faq.a2":
"Почніть із внутрішнього тестування з командою. OpenCode за замовчуванням не зберігає код. Потім зв'яжіться з нами для обговорення цін.",
"enterprise.faq.q3": "Як працює ціноутворення enterprise?",
"enterprise.faq.a3":
"Ми пропонуємо ціну за робоче місце. Якщо у вас власний LLM-шлюз, ми не стягуємо плату за токени.",
"enterprise.faq.q4": "Чи безпечні мої дані з OpenCode Enterprise?",
"enterprise.faq.a4":
"Так. OpenCode не зберігає ваш код або контекст. Вся обробка відбувається локально або через прямі API-виклики.",
"brand.title": "OpenCode | Бренд",
"brand.meta.description": "Рекомендації щодо бренду OpenCode",
"brand.heading": "Рекомендації щодо бренду",
"brand.subtitle": "Ресурси та матеріали для роботи з брендом OpenCode.",
"brand.downloadAll": "Завантажити всі матеріали",
"changelog.title": "OpenCode | Журнал змін",
"changelog.meta.description": "Нотатки про випуски та журнал змін OpenCode",
"changelog.hero.title": "Журнал змін",
"changelog.hero.subtitle": "Нові оновлення та покращення OpenCode",
"changelog.empty": "Записів у журналі змін не знайдено.",
"changelog.viewJson": "Переглянути JSON",
"bench.list.title": "Бенчмарк",
"bench.list.heading": "Бенчмарки",
"bench.list.table.agent": "Агент",
"bench.list.table.model": "Модель",
"bench.list.table.score": "Результат",
"bench.submission.error.allFieldsRequired": "Усі поля обов'язкові.",
"bench.detail.title": "Бенчмарк — {{task}}",
"bench.detail.notFound": "Завдання не знайдено",
"bench.detail.na": "Н/Д",
"bench.detail.labels.agent": "Агент",
"bench.detail.labels.model": "Модель",
"bench.detail.labels.task": "Завдання",
"bench.detail.labels.repo": "Репозиторій",
"bench.detail.labels.from": "Від",
"bench.detail.labels.to": "До",
"bench.detail.labels.prompt": "Prompt",
"bench.detail.labels.commit": "Коміт",
"bench.detail.labels.averageDuration": "Середня тривалість",
"bench.detail.labels.averageScore": "Середній результат",
"bench.detail.labels.averageCost": "Середня вартість",
"bench.detail.labels.summary": "Підсумок",
"bench.detail.labels.runs": "Запуски",
"bench.detail.labels.score": "Результат",
"bench.detail.labels.base": "База",
"bench.detail.labels.penalty": "Штраф",
"bench.detail.labels.weight": "вага",
"bench.detail.table.run": "Запуск",
"bench.detail.table.score": "Результат (База — Штраф)",
"bench.detail.table.cost": "Вартість",
"bench.detail.table.duration": "Тривалість",
"bench.detail.run.title": "Запуск {{n}}",
"bench.detail.rawJson": "Сирий JSON",
}
+33
View File
@@ -643,6 +643,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "选择付款方式",
"workspace.referral.copyLink": "复制链接",
"workspace.referral.copied": "已复制",
"workspace.referral.overview.title": "邀请好友",
"workspace.referral.overview.subtitle": "好友订阅后,您可获得 $5,对方也可获得 $5。",
"workspace.referral.instructions.share": "分享您的推荐链接。",
"workspace.referral.instructions.subscribe": "好友加入并订阅 Go。",
"workspace.referral.instructions.claim": "你们都将获得 $5 使用额度,可用于您的 Go 使用限额。",
"workspace.referral.rewards.title": "邀请奖励",
"workspace.referral.rewards.description": "将可用的邀请积分应用到您的 Go 用量。",
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 个奖励。",
"workspace.referral.rewards.empty": "暂无邀请奖励。",
"workspace.referral.table.reward": "奖励",
"workspace.referral.table.referral": "描述",
"workspace.referral.table.date": "日期",
"workspace.referral.reward.description.inviter": "已邀请 {{email}}",
"workspace.referral.reward.description.invitee": "由 {{email}} 邀请",
"workspace.referral.reward.action.subscribeUnlock": "订阅以解锁",
"workspace.referral.reward.action.view": "查看奖励",
"workspace.referral.reward.action.applied": "奖励已使用",
"workspace.referral.reward.source.pendingInviter": "等待对方订阅",
"workspace.referral.reward.source.pendingInvitee": "订阅即可解锁奖励",
"workspace.referral.reward.source.available": "奖励可使用",
"workspace.referral.reward.source.applied": "奖励已使用",
"workspace.referral.reward.status.applied": "奖励已使用",
"workspace.referral.reward.status.pendingInviter": "订阅以解锁",
"workspace.referral.reward.status.pendingInvitee": "订阅以解锁",
"workspace.referral.apply.noGo": "订阅以解锁",
"workspace.referral.apply.preview": "查看奖励",
"workspace.referral.apply.action": "使用",
"workspace.referral.apply.confirmTitle": "使用奖励",
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣当前工作区的用量。",
"workspace.referral.apply.confirmAction": "使用",
"download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
"download.hero.title": "下载 OpenCode",
+33
View File
@@ -643,6 +643,39 @@ export const dict = {
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "選擇付款方式",
"workspace.referral.copyLink": "複製連結",
"workspace.referral.copied": "已複製",
"workspace.referral.overview.title": "邀請朋友",
"workspace.referral.overview.subtitle": "朋友訂閱後,您可獲得 $5,對方也可獲得 $5。",
"workspace.referral.instructions.share": "分享您的推薦連結。",
"workspace.referral.instructions.subscribe": "朋友加入並訂閱 Go。",
"workspace.referral.instructions.claim": "你們都將獲得 $5 使用額度,可用於您的 Go 使用限額。",
"workspace.referral.rewards.title": "邀請獎勵",
"workspace.referral.rewards.description": "將可用的邀請點數套用至您的 Go 使用量。",
"workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 個獎勵。",
"workspace.referral.rewards.empty": "暫無邀請獎勵。",
"workspace.referral.table.reward": "獎勵",
"workspace.referral.table.referral": "描述",
"workspace.referral.table.date": "日期",
"workspace.referral.reward.description.inviter": "已邀請 {{email}}",
"workspace.referral.reward.description.invitee": "由 {{email}} 邀請",
"workspace.referral.reward.action.subscribeUnlock": "訂閱以解鎖",
"workspace.referral.reward.action.view": "查看獎勵",
"workspace.referral.reward.action.applied": "獎勵已使用",
"workspace.referral.reward.source.pendingInviter": "等待對方訂閱",
"workspace.referral.reward.source.pendingInvitee": "訂閱即可解鎖獎勵",
"workspace.referral.reward.source.available": "獎勵可使用",
"workspace.referral.reward.source.applied": "獎勵已使用",
"workspace.referral.reward.status.applied": "獎勵已使用",
"workspace.referral.reward.status.pendingInviter": "訂閱以解鎖",
"workspace.referral.reward.status.pendingInvitee": "訂閱以解鎖",
"workspace.referral.apply.noGo": "訂閱以解鎖",
"workspace.referral.apply.preview": "查看獎勵",
"workspace.referral.apply.action": "使用",
"workspace.referral.apply.confirmTitle": "使用獎勵",
"workspace.referral.apply.confirmBody": "使用 {{amount}} 抵扣目前工作區的用量。",
"workspace.referral.apply.confirmAction": "使用",
"download.title": "OpenCode | 下載",
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
"download.hero.title": "下載 OpenCode",
@@ -0,0 +1,47 @@
import type { Key } from "~/i18n"
import type { useI18n } from "~/context/i18n"
type ResetTimeKeys = {
day: Key
days: Key
hour: Key
hours: Key
minute: Key
minutes: Key
fewSeconds: Key
}
export const liteResetTimeKeys = {
day: "workspace.lite.time.day",
days: "workspace.lite.time.days",
hour: "workspace.lite.time.hour",
hours: "workspace.lite.time.hours",
minute: "workspace.lite.time.minute",
minutes: "workspace.lite.time.minutes",
fewSeconds: "workspace.lite.time.fewSeconds",
} satisfies ResetTimeKeys
export const blackResetTimeKeys = {
day: "workspace.black.time.day",
days: "workspace.black.time.days",
hour: "workspace.black.time.hour",
hours: "workspace.black.time.hours",
minute: "workspace.black.time.minute",
minutes: "workspace.black.time.minutes",
fewSeconds: "workspace.black.time.fewSeconds",
} satisfies ResetTimeKeys
export function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>, keys: ResetTimeKeys) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t(keys.day) : i18n.t(keys.days)} ${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t(keys.hour) : i18n.t(keys.hours)} ${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
if (minutes === 0) return i18n.t(keys.fewSeconds)
return `${minutes} ${minutes === 1 ? i18n.t(keys.minute) : i18n.t(keys.minutes)}`
}
+7
View File
@@ -11,6 +11,7 @@ export const LOCALES = [
"ja",
"pl",
"ru",
"uk",
"ar",
"no",
"br",
@@ -41,6 +42,7 @@ const LABEL = {
ja: "日本語",
pl: "Polski",
ru: "Русский",
uk: "Українська",
ar: "العربية",
no: "Norsk",
br: "Português (Brasil)",
@@ -61,6 +63,7 @@ const TAG = {
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "no",
br: "pt-BR",
@@ -81,6 +84,7 @@ const DOCS = {
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb",
br: "pt-br",
@@ -104,6 +108,7 @@ const DOCS_SEGMENT = new Set([
"ru",
"th",
"tr",
"uk",
"zh-cn",
"zh-tw",
])
@@ -124,6 +129,7 @@ const DOCS_LOCALE = {
ru: "ru",
th: "th",
tr: "tr",
uk: "uk",
"zh-cn": "zh",
"zh-tw": "zht",
} as const satisfies Record<string, Locale>
@@ -239,6 +245,7 @@ function match(input: string): Locale | null {
if (value.startsWith("ja")) return "ja"
if (value.startsWith("pl")) return "pl"
if (value.startsWith("ru")) return "ru"
if (value.startsWith("uk")) return "uk"
if (value.startsWith("ar")) return "ar"
if (value.startsWith("tr")) return "tr"
if (value.startsWith("th")) return "th"
@@ -0,0 +1,28 @@
import { Referral } from "@opencode-ai/console-core/referral.js"
const REFERRAL_COOKIE = "oc_referral"
const REFERRAL_MAX_AGE = 60 * 60 * 24 * 30
export function normalizeReferralCode(code?: string | null) {
return Referral.normalizeCode(code)
}
export function referralCookie(code: string) {
return `${REFERRAL_COOKIE}=${encodeURIComponent(code)}; Path=/; Max-Age=${REFERRAL_MAX_AGE}; SameSite=Lax; HttpOnly`
}
export function clearReferralCookie() {
return `${REFERRAL_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly`
}
export function referralCodeFromCookieHeader(header: string | null) {
if (!header) return undefined
return normalizeReferralCode(
header
.split(";")
.map((x) => x.trim())
.find((x) => x.startsWith(`${REFERRAL_COOKIE}=`))
?.slice(`${REFERRAL_COOKIE}=`.length),
)
}
+10 -6
View File
@@ -1,16 +1,20 @@
import { createMiddleware } from "@solidjs/start/middleware"
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
export default createMiddleware({
onRequest(event) {
const url = new URL(event.request.url)
const locale = fromPathname(url.pathname)
if (!locale) return
if (locale) {
url.pathname = strip(url.pathname)
const request = new Request(url, event.request)
request.headers.set(LOCALE_HEADER, locale)
event.request = request
event.response.headers.append("set-cookie", cookie(locale))
}
url.pathname = strip(url.pathname)
const request = new Request(url, event.request)
request.headers.set(LOCALE_HEADER, locale)
event.request = request
event.response.headers.append("set-cookie", cookie(locale))
const referralCode = normalizeReferralCode(url.searchParams.get("ref"))
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode))
},
})
@@ -1,9 +1,11 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -17,6 +19,7 @@ export async function GET(input: APIEvent) {
if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
@@ -32,8 +35,15 @@ export async function GET(input: APIEvent) {
current: id,
}
})
if (decoded.subject.properties.newAccount && referralCode) {
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
console.error("Referral create failed", error)
})
}
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
return redirect(route(locale, next))
const response = redirect(route(locale, next))
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
return response
} catch (e: any) {
return new Response(
JSON.stringify({
+61 -59
View File
@@ -700,65 +700,6 @@
text-decoration: underline;
}
}
[data-slot="workspace-picker"] {
[data-slot="workspace-list"] {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="workspace-item"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
&:hover,
&[data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
}
}
}
@@ -839,3 +780,64 @@
}
}
}
[data-component="black-workspace-picker-modal"] {
font-family: var(--font-mono);
[data-slot="workspace-list"] {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="workspace-item"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
&:hover,
&[data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
@@ -444,8 +444,13 @@ export default function BlackSubscribe() {
</div>
{/* Workspace picker modal */}
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
<div data-slot="workspace-picker">
<Modal
open={showWorkspacePicker() ?? false}
onClose={() => {}}
title={i18n.t("black.workspace.selectPlan")}
variant="black"
>
<div data-component="black-workspace-picker-modal" data-slot="workspace-picker">
<ul
ref={listRef}
data-slot="workspace-list"
@@ -9,7 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { User } from "@opencode-ai/console-core/user.js"
import { Referral } from "@opencode-ai/console-core/referral.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -174,6 +174,13 @@ export async function POST(input: APIEvent) {
}
}
})
await Referral.completeFromLiteSubscription({
workspaceID,
userID,
}).catch((error) => {
console.error("Referral sync failed", error)
})
})
}
}
@@ -34,6 +34,10 @@
background-color: var(--color-bg-surface);
}
}
}
[data-component="workspace-create-modal"] {
width: 100%;
[data-slot="create-form"] {
width: 100%;
@@ -1,6 +1,5 @@
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
import { For, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { For, createEffect, createSignal } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
@@ -51,9 +50,7 @@ export function WorkspacePicker() {
const i18n = useI18n()
const workspaces = createAsync(() => getWorkspaces())
const submission = useSubmission(createWorkspace)
const [store, setStore] = createStore({
showForm: false,
})
const [showForm, setShowForm] = createSignal(false)
let inputRef: HTMLInputElement | undefined
const currentWorkspace = () => {
@@ -61,12 +58,8 @@ export function WorkspacePicker() {
return ws ? ws.name : i18n.t("workspace.select")
}
const handleWorkspaceNew = () => {
setStore("showForm", true)
}
createEffect(() => {
if (store.showForm && inputRef) {
if (showForm() && inputRef) {
setTimeout(() => inputRef?.focus(), 0)
}
})
@@ -79,7 +72,7 @@ export function WorkspacePicker() {
// Reset signals when workspace ID changes
createEffect(() => {
params.id
setStore("showForm", false)
setShowForm(false)
})
return (
@@ -92,32 +85,34 @@ export function WorkspacePicker() {
</DropdownItem>
)}
</For>
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
<button data-slot="create-item" type="button" onClick={() => setShowForm(true)}>
{i18n.t("workspace.createNew")}
</button>
</Dropdown>
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title={i18n.t("workspace.modal.title")}>
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
<input
ref={inputRef}
data-slot="create-input"
type="text"
name="workspaceName"
placeholder={i18n.t("workspace.modal.placeholder")}
required
/>
<div data-slot="button-group">
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
<Modal open={showForm()} onClose={() => setShowForm(false)} title={i18n.t("workspace.modal.title")}>
<div data-component="workspace-create-modal">
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
<input
ref={inputRef}
data-slot="create-input"
type="text"
name="workspaceName"
placeholder={i18n.t("workspace.modal.placeholder")}
required
/>
<div data-slot="button-group">
<button type="button" data-color="ghost" onClick={() => setShowForm(false)}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
</div>
</div>
</div>
</form>
</form>
</div>
</Modal>
</div>
)
@@ -25,6 +25,7 @@
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
@@ -13,6 +13,7 @@ import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css"
import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error"
import { blackResetTimeKeys, formatResetTime } from "~/lib/format-reset-time"
const querySubscription = query(async (workspaceID: string) => {
"use server"
@@ -52,20 +53,6 @@ const querySubscription = query(async (workspaceID: string) => {
}, workspaceID)
}, "subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
}
const cancelWaitlist = action(async (workspaceID: string) => {
"use server"
return json(
@@ -209,7 +196,7 @@ export function BlackSection() {
</div>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
{formatResetTime(sub().rollingUsage.resetInSec, i18n, blackResetTimeKeys)}
</span>
</div>
<div data-slot="usage-item">
@@ -222,7 +209,7 @@ export function BlackSection() {
</div>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n, blackResetTimeKeys)}
</span>
</div>
</div>
@@ -1,11 +1,17 @@
import { createAsync, useParams } from "@solidjs/router"
import { Show } from "solid-js"
import { IconGo } from "~/component/icon"
import { GoReferralSection, queryGoReferral } from "~/component/go-referral"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LiteSection } from "./lite-section"
import { LiteSection, queryLiteSubscription } from "./lite-section"
export default function () {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const referral = createAsync(() => queryGoReferral(params.id!))
const lite = createAsync(() => queryLiteSubscription(params.id!))
return (
<div data-page="workspace-[id]">
@@ -23,7 +29,10 @@ export default function () {
</section>
<div data-slot="sections">
<LiteSection />
<LiteSection lite={lite()} />
<Show when={referral()} fallback={<section>{i18n.t("workspace.lite.loading")}</section>}>
{(summary) => <GoReferralSection workspaceID={params.id!} summary={summary()} lite={lite()} />}
</Show>
</div>
</div>
)
@@ -211,7 +211,9 @@
align-items: center;
gap: 4px;
}
}
.paymentMethodModal {
[data-slot="modal-actions"] {
display: flex;
gap: var(--space-3);
@@ -14,10 +14,11 @@ import styles from "./lite-section.module.css"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { IconAlipay, IconUpi } from "~/component/icon"
const queryLiteSubscription = query(async (workspaceID: string) => {
export const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const row = await Database.use((tx) =>
@@ -67,19 +68,7 @@ const queryLiteSubscription = query(async (workspaceID: string) => {
}, workspaceID)
}, "lite.subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
type LiteSubscription = Awaited<ReturnType<typeof queryLiteSubscription>>
const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
@@ -140,13 +129,32 @@ const setLiteUseBalance = action(async (form: FormData) => {
)
}, "setLiteUseBalance")
export function LiteSection() {
function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) {
const i18n = useI18n()
return (
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{props.label}</span>
<span data-slot="usage-value">{props.usage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${props.usage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(props.usage.resetInSec, i18n, liteResetTimeKeys)}
</span>
</div>
)
}
export function LiteSection(props: { lite: LiteSubscription | undefined }) {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
const lite = createAsync(() => queryLiteSubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const checkoutAction = useAction(createLiteCheckoutUrl)
@@ -186,7 +194,7 @@ export function LiteSection() {
<p data-slot="other-message">{i18n.t("workspace.lite.black.message")}</p>
</section>
</Show>
<Show when={!isBlack() && lite() && lite()!.mine && lite()!}>
<Show when={!isBlack() && props.lite && props.lite.mine && props.lite}>
{(sub) => (
<section class={styles.root}>
<div data-slot="section-title">
@@ -207,44 +215,9 @@ export function LiteSection() {
.
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
</span>
</div>
<LiteUsageItem label={i18n.t("workspace.lite.subscription.rollingUsage")} usage={sub().rollingUsage} />
<LiteUsageItem label={i18n.t("workspace.lite.subscription.weeklyUsage")} usage={sub().weeklyUsage} />
<LiteUsageItem label={i18n.t("workspace.lite.subscription.monthlyUsage")} usage={sub().monthlyUsage} />
</div>
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
@@ -263,12 +236,12 @@ export function LiteSection() {
</section>
)}
</Show>
<Show when={!isBlack() && lite() && !lite()!.mine}>
<Show when={!isBlack() && props.lite && !props.lite.mine}>
<section class={styles.root}>
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
</section>
</Show>
<Show when={!isBlack() && lite() === null}>
<Show when={!isBlack() && props.lite === null}>
<section class={styles.root}>
<p data-slot="promo-description">
<For
@@ -330,31 +303,33 @@ export function LiteSection() {
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
>
<div data-slot="modal-actions">
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("alipay")}
>
<Show when={store.loading !== "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Show>
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
</button>
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("upi")}
>
<Show when={store.loading !== "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Show>
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
</button>
<div class={styles.paymentMethodModal}>
<div data-slot="modal-actions">
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("alipay")}
>
<Show when={store.loading !== "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Show>
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
</button>
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("upi")}
>
<Show when={store.loading !== "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Show>
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
</button>
</div>
</div>
</Modal>
</section>
@@ -10,6 +10,7 @@ import {
IconAnthropic,
IconArcee,
IconGemini,
IconDeepSeek,
IconMiniMax,
IconMoonshotAI,
IconNvidia,
@@ -27,6 +28,7 @@ const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("deepseek")) return "DeepSeek"
if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba"
@@ -47,7 +49,19 @@ const getModelsInfo = query(async (workspaceID: string) => {
.filter(([id, _model]) => !id.startsWith("alpha-"))
.filter(([id, _model]) => !id.endsWith(":global"))
.sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"]
const priority = [
"big-pickle",
"claude",
"gpt",
"gemini",
"deepseek",
"glm",
"kimi",
"qwen",
"grok",
"minimax",
"mimo",
]
const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index
@@ -136,6 +150,8 @@ export function ModelSection() {
return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGemini width={16} height={16} />
case "DeepSeek":
return <IconDeepSeek width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":
@@ -170,6 +170,10 @@ export async function handler(
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v === "$session") return sessionId ? [[k, sessionId]] : []
if (v === "$user") {
const user = sessionId ?? authInfo?.workspaceID ?? ip
return user ? [[k, user]] : []
}
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
@@ -13,7 +13,7 @@ export function createRateLimiter(
if (!zenApiKey) return
const dict = i18n(localeFromRequest(request))
const LIMIT = rateLimit ?? 500
const LIMIT = rateLimit ?? 1000
const yyyyMMddHHmm = new Date(Date.now())
.toISOString()
.replace(/[^0-9]/g, "")
@@ -0,0 +1,47 @@
CREATE TABLE `referral_code` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`code` varchar(10) NOT NULL,
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
CONSTRAINT `referral_code_workspace_id` UNIQUE INDEX(`workspace_id`),
CONSTRAINT `referral_code_code` UNIQUE INDEX(`code`)
);
--> statement-breakpoint
CREATE TABLE `referral_reward` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`referral_id` varchar(30) NOT NULL,
`source` enum('inviter','invitee') NOT NULL,
`amount` bigint NOT NULL,
`applied_by_user_id` varchar(30),
`time_applied` timestamp(3),
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
CONSTRAINT `referral_reward_referral_source` UNIQUE INDEX(`referral_id`,`source`)
);
--> statement-breakpoint
CREATE TABLE `referral` (
`id` varchar(30) NOT NULL,
`workspace_id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`inviter_workspace_id` varchar(30) NOT NULL,
`invitee_account_id` varchar(30) NOT NULL,
`invitee_user_id` varchar(30) NOT NULL,
`referral_code_id` varchar(30) NOT NULL,
`stripe_customer_id` varchar(255) NOT NULL,
`stripe_subscription_id` varchar(255) NOT NULL,
CONSTRAINT PRIMARY KEY(`workspace_id`,`id`),
CONSTRAINT `referral_invitee_account_id` UNIQUE INDEX(`invitee_account_id`),
CONSTRAINT `referral_stripe_subscription_id` UNIQUE INDEX(`stripe_subscription_id`)
);
--> statement-breakpoint
CREATE INDEX `referral_reward_workspace_time` ON `referral_reward` (`workspace_id`,`time_created`);--> statement-breakpoint
CREATE INDEX `referral_inviter_workspace_id` ON `referral` (`inviter_workspace_id`);--> statement-breakpoint
CREATE INDEX `referral_code_id` ON `referral` (`referral_code_id`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
DROP TABLE `referral_code`;--> statement-breakpoint
DROP INDEX `referral_reward_referral_source` ON `referral_reward`;--> statement-breakpoint
DROP INDEX `referral_stripe_subscription_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_inviter_workspace_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_code_id` ON `referral`;--> statement-breakpoint
ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral_reward` MODIFY COLUMN `workspace_id` varchar(30);--> statement-breakpoint
ALTER TABLE `workspace` ADD `referral_code` varchar(16);--> statement-breakpoint
ALTER TABLE `referral_reward` ADD PRIMARY KEY (`id`);--> statement-breakpoint
ALTER TABLE `referral` ADD PRIMARY KEY (`id`);--> statement-breakpoint
CREATE INDEX `referral_workspace_id` ON `referral` (`workspace_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `workspace_referral_code` ON `workspace` (`referral_code`);--> statement-breakpoint
ALTER TABLE `referral_reward` DROP COLUMN `source`;--> statement-breakpoint
ALTER TABLE `referral_reward` DROP COLUMN `applied_by_user_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `inviter_workspace_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `invitee_user_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `referral_code_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `stripe_customer_id`;--> statement-breakpoint
ALTER TABLE `referral` DROP COLUMN `stripe_subscription_id`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
DROP INDEX `referral_reward_workspace_time` ON `referral_reward`;--> statement-breakpoint
ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral_reward` ADD PRIMARY KEY (`workspace_id`,`referral_id`);--> statement-breakpoint
ALTER TABLE `referral_reward` DROP COLUMN `id`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
DROP INDEX `referral_workspace_id` ON `referral`;--> statement-breakpoint
ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `referral` ADD PRIMARY KEY (`workspace_id`,`id`);
@@ -0,0 +1,2 @@
UPDATE `workspace` SET `referral_code` = NULL WHERE CHAR_LENGTH(`referral_code`) > 10;--> statement-breakpoint
ALTER TABLE `workspace` MODIFY COLUMN `referral_code` varchar(10);
@@ -0,0 +1,2 @@
DROP INDEX `workspace_referral_code` ON `workspace`;--> statement-breakpoint
CREATE UNIQUE INDEX `referral_code` ON `workspace` (`referral_code`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
CREATE TABLE `referral_code` (
`workspace_id` varchar(30) PRIMARY KEY,
`code` varchar(10) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
CONSTRAINT `code` UNIQUE INDEX(`code`)
);
--> statement-breakpoint
DROP INDEX `referral_invitee_account_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_code` ON `workspace`;--> statement-breakpoint
CREATE UNIQUE INDEX `invitee_account_id` ON `referral` (`invitee_account_id`);--> statement-breakpoint
ALTER TABLE `workspace` DROP COLUMN `referral_code`;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.6",
"private": true,
"type": "module",
"license": "MIT",
+20
View File
@@ -155,6 +155,26 @@ export namespace Billing {
return amountInMicroCents
}
export const subtractLiteUsage = async (workspaceID: string, amountInMicroCents: number) => {
await Database.transaction(async (tx) => {
const lite = await tx
.select({ id: LiteTable.id })
.from(LiteTable)
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
.then((rows) => rows[0])
if (!lite) throw new Error("Subscribe to Go before applying referral rewards")
await tx
.update(LiteTable)
.set({
monthlyUsage: sql`GREATEST(0, COALESCE(${LiteTable.monthlyUsage}, 0) - ${amountInMicroCents})`,
weeklyUsage: sql`GREATEST(0, COALESCE(${LiteTable.weeklyUsage}, 0) - ${amountInMicroCents})`,
rollingUsage: sql`GREATEST(0, COALESCE(${LiteTable.rollingUsage}, 0) - ${amountInMicroCents})`,
})
.where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted)))
})
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
// validate coupon type
await (async () => {
+1
View File
@@ -12,6 +12,7 @@ export namespace Identifier {
model: "mod",
payment: "pay",
provider: "prv",
referral: "ref",
subscription: "sub",
usage: "usg",
user: "usr",
+390
View File
@@ -0,0 +1,390 @@
import { z } from "zod"
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { LiteTable } from "./schema/billing.sql"
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { centsToMicroCents, microCentsToCents } from "./util/price"
import { fn } from "./util/fn"
import { Billing } from "./billing"
import { LiteData } from "./lite"
import { Subscription } from "./subscription"
import { ulid } from "ulid"
export namespace Referral {
export const REWARD_AMOUNT = centsToMicroCents(500)
export const CODE_LENGTH = 10
export function normalizeCode(code?: string | null) {
return code
?.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, CODE_LENGTH)
}
function generateCode() {
return ulid().slice(-CODE_LENGTH).toUpperCase()
}
async function ensureCode(workspaceID = Actor.workspace()) {
return Database.use(async (db) => {
const existing = await db
.select({ code: ReferralCodeTable.code })
.from(ReferralCodeTable)
.where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0])
if (existing) return { code: existing.code }
await db.insert(ReferralCodeTable).ignore().values({
workspaceID,
code: generateCode(),
})
const created = await db
.select({ code: ReferralCodeTable.code })
.from(ReferralCodeTable)
.where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0])
if (created) return { code: created.code }
throw new Error("Failed to generate referral code")
})
}
export const summary = fn(z.void(), async () => {
const workspaceID = Actor.workspace()
const accountID = Actor.account()
const code = await ensureCode(workspaceID)
const rows = await Database.use(async (tx) => {
const [rewards, invites, inviteeReferral, inviteeRewards] = await Promise.all([
tx
.select({
referralID: ReferralRewardTable.referralID,
workspaceID: ReferralRewardTable.workspaceID,
referralWorkspaceID: ReferralTable.workspaceID,
inviteeEmail: AuthTable.subject,
amount: ReferralRewardTable.amount,
timeCreated: ReferralRewardTable.timeCreated,
timeApplied: ReferralRewardTable.timeApplied,
})
.from(ReferralRewardTable)
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
.innerJoin(
AuthTable,
and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")),
)
.where(
and(
eq(ReferralRewardTable.workspaceID, workspaceID),
isNull(ReferralRewardTable.timeDeleted),
isNull(ReferralTable.timeDeleted),
),
),
tx
.select({ id: ReferralTable.id, inviteeEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
.from(ReferralTable)
.innerJoin(
AuthTable,
and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")),
)
.where(and(eq(ReferralTable.workspaceID, workspaceID), isNull(ReferralTable.timeDeleted))),
tx
.select({ id: ReferralTable.id, inviterEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
.from(ReferralTable)
.leftJoin(
UserTable,
and(
eq(UserTable.workspaceID, ReferralTable.workspaceID),
eq(UserTable.role, "admin"),
isNull(UserTable.timeDeleted),
),
)
.leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(ReferralTable.inviteeAccountID, accountID), isNull(ReferralTable.timeDeleted)))
.orderBy(asc(UserTable.timeCreated))
.then((rows) => rows.find((row) => row.inviterEmail) ?? rows[0]),
tx
.select({ referralID: ReferralRewardTable.referralID })
.from(ReferralRewardTable)
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
.where(
and(
eq(ReferralTable.inviteeAccountID, accountID),
isNull(ReferralRewardTable.timeDeleted),
isNull(ReferralTable.timeDeleted),
),
),
])
return { inviteeReferral, inviteeRewards, invites, rewards }
})
const rewardReferralIDs = new Set(rows.rewards.map((reward) => reward.referralID))
const inviteeRewardReferralIDs = new Set(rows.inviteeRewards.map((reward) => reward.referralID))
const rewards = rows.rewards.map((reward) => {
const source = reward.workspaceID === reward.referralWorkspaceID ? ("inviter" as const) : ("invitee" as const)
return {
id: reward.referralID,
source,
status: reward.timeApplied ? ("applied" as const) : ("available" as const),
email: source === "invitee" ? (rows.inviteeReferral?.inviterEmail ?? null) : reward.inviteeEmail,
amount: microCentsToCents(reward.amount),
timeCreated: reward.timeCreated,
timeApplied: reward.timeApplied,
}
})
const pending = [
...rows.invites
.filter((referral) => !rewardReferralIDs.has(referral.id))
.map((referral) => ({
id: `${referral.id}:inviter`,
source: "inviter" as const,
status: "pending" as const,
email: referral.inviteeEmail,
amount: microCentsToCents(REWARD_AMOUNT),
timeCreated: referral.timeCreated,
timeApplied: null,
})),
...(rows.inviteeReferral && !inviteeRewardReferralIDs.has(rows.inviteeReferral.id)
? [
{
id: `${rows.inviteeReferral.id}:invitee`,
source: "invitee" as const,
status: "pending" as const,
email: rows.inviteeReferral.inviterEmail,
amount: microCentsToCents(REWARD_AMOUNT),
timeCreated: rows.inviteeReferral.timeCreated,
timeApplied: null,
},
]
: []),
]
const allRewards = [...pending, ...rewards].sort(
(a, b) => new Date(b.timeCreated).getTime() - new Date(a.timeCreated).getTime(),
)
return {
referralCode: code.code,
hasReferral: allRewards.length > 0,
rewardAmount: microCentsToCents(REWARD_AMOUNT),
rewards: allRewards,
}
})
export const applyReward = fn(z.object({ referralID: z.string() }), async (input) => {
const workspaceID = Actor.workspace()
return Database.transaction(async (tx) => {
const reward = await tx
.select({ amount: ReferralRewardTable.amount, timeApplied: ReferralRewardTable.timeApplied })
.from(ReferralRewardTable)
.where(
and(
eq(ReferralRewardTable.workspaceID, workspaceID),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!reward) throw new Error("Referral reward not found")
if (reward.timeApplied) throw new Error("Referral reward already applied")
const update = await tx
.update(ReferralRewardTable)
.set({
timeApplied: sql`now()`,
})
.where(
and(
eq(ReferralRewardTable.workspaceID, workspaceID),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeApplied),
isNull(ReferralRewardTable.timeDeleted),
),
)
if (update.rowsAffected === 0) throw new Error("Referral reward already applied")
await Billing.subtractLiteUsage(workspaceID, reward.amount)
return { amount: microCentsToCents(reward.amount) }
})
})
export const usagePreview = fn(z.object({ referralID: z.string() }), async (input) => {
const row = await Database.use((tx) =>
tx
.select({
rewardAmount: ReferralRewardTable.amount,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
})
.from(ReferralRewardTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, ReferralRewardTable.workspaceID))
.where(
and(
eq(ReferralRewardTable.workspaceID, Actor.workspace()),
eq(ReferralRewardTable.referralID, input.referralID),
isNull(ReferralRewardTable.timeApplied),
isNull(ReferralRewardTable.timeDeleted),
isNull(LiteTable.timeDeleted),
),
)
.then((rows) => rows[0]),
)
if (!row) return null
const limits = LiteData.getLimits()
return {
rollingUsage: usagePreviewItem(
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: Math.max(0, (row.rollingUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
),
weeklyUsage: usagePreviewItem(
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: Math.max(0, (row.weeklyUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
),
monthlyUsage: usagePreviewItem(
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: Math.max(0, (row.monthlyUsage ?? 0) - row.rewardAmount),
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
),
}
})
export async function createFromAccount(input: { accountID: string; referralCode?: string }) {
const referralCode = normalizeCode(input.referralCode)
if (!referralCode) return
return Database.transaction(async (tx) => {
const code = await tx
.select({ workspaceID: ReferralCodeTable.workspaceID })
.from(ReferralCodeTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, ReferralCodeTable.workspaceID))
.where(and(eq(ReferralCodeTable.code, referralCode), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0])
if (!code) throw new Error("Referral code invalid")
const existingReferral = await tx
.select({ id: ReferralTable.id })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (existingReferral) throw new Error("Referral already redeemed")
const selfReferral = await tx
.select({ id: UserTable.id })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, code.workspaceID),
eq(UserTable.accountID, input.accountID),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (selfReferral) throw new Error("Self-referral is not allowed")
const referralID = Identifier.create("referral")
await tx.insert(ReferralTable).ignore().values({
workspaceID: code.workspaceID,
id: referralID,
inviteeAccountID: input.accountID,
})
const referral = await tx
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error("Referral not created")
if (referral.id !== referralID) throw new Error("Referral already redeemed")
})
}
export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
return Database.transaction(async (tx) => {
const invitee = await tx
.select({ accountID: UserTable.accountID })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, input.workspaceID),
eq(UserTable.id, input.userID),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!invitee?.accountID) throw new Error("Referral invitee account missing")
const referral = await tx
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error("Referral not found")
const result = await tx
.insert(ReferralRewardTable)
.ignore()
.values([
{
workspaceID: referral.workspaceID,
referralID: referral.id,
amount: REWARD_AMOUNT,
},
{
workspaceID: input.workspaceID,
referralID: referral.id,
amount: REWARD_AMOUNT,
},
])
if (result.rowsAffected === 0) throw new Error("Referral already completed")
})
}
function usagePreviewItem(
before: { usagePercent: number; resetInSec: number },
after: { usagePercent: number; resetInSec: number },
) {
return {
beforePercent: before.usagePercent,
afterPercent: after.usagePercent,
resetInSec: after.resetInSec,
}
}
}
@@ -0,0 +1,35 @@
import { bigint, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
export const ReferralCodeTable = mysqlTable(
"referral_code",
{
workspaceID: ulid("workspace_id").notNull(),
code: varchar("code", { length: 10 }).notNull(),
...timestamps,
},
(table) => [primaryKey({ columns: [table.workspaceID] }), uniqueIndex("code").on(table.code)],
)
export const ReferralTable = mysqlTable(
"referral",
{
...workspaceColumns,
...timestamps,
inviteeAccountID: ulid("invitee_account_id").notNull(),
},
(table) => [...workspaceIndexes(table), uniqueIndex("invitee_account_id").on(table.inviteeAccountID)],
)
export const ReferralRewardTable = mysqlTable(
"referral_reward",
{
workspaceID: ulid("workspace_id").notNull(),
referralID: ulid("referral_id").notNull(),
...timestamps,
amount: bigint("amount", { mode: "number" }).notNull(),
timeApplied: utc("time_applied"),
},
(table) => [primaryKey({ columns: [table.workspaceID, table.referralID] })],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4 -1
View File
@@ -26,6 +26,7 @@ export const subjects = createSubjects({
account: z.object({
accountID: z.string(),
email: z.string(),
newAccount: z.boolean().optional(),
}),
user: z.object({
userID: z.string(),
@@ -142,6 +143,7 @@ export default {
}
// Get account
let newAccount = false
const accountID = await (async () => {
const matches = await Database.use(async (tx) =>
tx
@@ -166,6 +168,7 @@ export default {
if (!accountID) {
console.log("creating account for", email)
accountID = await Account.create({})
newAccount = true
}
await Database.use(async (tx) =>
@@ -215,7 +218,7 @@ export default {
await Workspace.create({ name: "Default" })
}
})
return ctx.subject("account", accountID, { accountID, email })
return ctx.subject("account", accountID, { accountID, email, newAccount })
},
}).fetch(request, env, ctx)
return result
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.6",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+319
View File
@@ -0,0 +1,319 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("AccountV2.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AccountV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AccountV2.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as AccountV2 from "./account"
+147
View File
@@ -0,0 +1,147 @@
export * as AgentV2 from "./agent"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
export type Mode = typeof Mode.Type
export const Info = Schema.Struct({
name: ID,
description: Schema.optional(Schema.String),
mode: Mode,
hidden: Schema.Boolean.pipe(Schema.optional),
color: Schema.String.pipe(Schema.optional),
permission: PermissionV2.Ruleset,
model: ModelV2.Ref.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
options: ProviderV2.Options.pipe(Schema.optional),
steps: Schema.Int.pipe(Schema.optional),
}).annotate({ identifier: "AgentV2.Info" })
export type Info = typeof Info.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
agent: ID,
}) {}
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
agent: ID,
reason: Schema.Literals(["missing", "subagent", "hidden"]),
}) {}
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
export interface Interface {
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
readonly list: () => Effect.Effect<Info[]>
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
readonly remove: (agent: ID) => Effect.Effect<void>
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let agents = HashMap.empty<ID, Info>()
let defaultAgent: ID | undefined
const result: Interface = {
get: Effect.fn("AgentV2.get")(function* (agent) {
const match = HashMap.get(agents, agent)
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
return match.value
}),
list: Effect.fn("AgentV2.list")(function* () {
return pipe(
HashMap.toValues(agents),
Array.sortWith((agent) => agent.name, Order.String),
)
}),
update: Effect.fnUntraced(function* (agent, fn) {
const next = produce(
HashMap.get(agents, agent).pipe(
Option.getOrElse(
() =>
({
name: agent,
mode: "all",
permission: [],
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
}) satisfies Info,
),
),
fn,
)
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
if (updated.cancel) return
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
}),
remove: Effect.fn("AgentV2.remove")(function* (agent) {
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
if (!existing) return
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
agents = HashMap.remove(agents, agent)
if (defaultAgent === agent) defaultAgent = undefined
}),
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
const selected = updated.agent
if (selected) {
const agent = yield* result
.get(selected)
.pipe(
Effect.catchTag("AgentV2.NotFound", () =>
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
),
)
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
return agent
}
const visible = pipe(
yield* result.list(),
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
)
if (Option.isSome(visible)) return visible.value
return yield* new NoDefaultError()
}),
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
return (yield* result.defaultInfo()).name
}),
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
yield* result.get(agent)
defaultAgent = agent
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
-264
View File
@@ -1,264 +0,0 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"
+141 -53
View File
@@ -1,6 +1,6 @@
export * as Catalog from "./catalog"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
@@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
type ProviderRecord = {
export type ProviderRecord = {
provider: ProviderV2.Info
models: HashMap.HashMap<ModelV2.ID, ModelV2.Info>
models: Map<ModelV2.ID, ModelV2.Info>
}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
@@ -34,10 +34,26 @@ export const Event = {
}),
}
export type Context = {
data: readonly ProviderRecord[]
updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
provider: {
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
}
export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
export interface Interface {
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
@@ -46,11 +62,6 @@ export interface Interface {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly update: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
fn: (model: Draft<ModelV2.Info>) => void,
) => Effect.Effect<void, ProviderNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
@@ -69,9 +80,11 @@ export const layer = Layer.effect(
Effect.gen(function* () {
yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let loaders: { update: (ctx: Context) => void }[] = []
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -112,29 +125,127 @@ export const layer = Layer.effect(
return match.value
}
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
item.endpoint.url = item.options.aisdk.provider.baseURL
delete item.options.aisdk.provider.baseURL
}
const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
HashMap.fromIterable(
HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const),
)
const context = (draft: {
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
data: ProviderRecord[]
}): Context => {
const result: Context = {
data: draft.data,
updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider,
models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID) => {
draft.records = HashMap.remove(draft.records, providerID)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data.splice(index, 1)
},
},
model: {
update: (providerID, modelID, fn) => {
const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider: current.provider,
models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID, modelID) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
if (!current) return
const next = {
provider: current.provider,
models: new Map(current.models),
}
next.models.delete(modelID)
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data[index] = next
},
},
}
return result
}
const transform = Effect.fn("CatalogV2.transform")(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
for (const loader of loaders) loader.update(context(draft))
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
yield* plugin.added().pipe(
Stream.runForEach((id) =>
Effect.gen(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
records = draft.records
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = {
loader: Effect.fn("CatalogV2.loader")(function* () {
const loader = { update: (_ctx: Context) => {} }
loaders = [...loaders, loader]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
loaders = loaders.filter((item) => item !== loader)
}).pipe(Effect.andThen(rebuild())),
)
return Effect.fnUntraced(function* (update) {
loader.update = update
yield* rebuild()
})
}),
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
}),
update: Effect.fnUntraced(function* (providerID, fn) {
const current = Option.getOrUndefined(HashMap.get(records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
})
const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
records = HashMap.set(records, providerID, {
provider: updated.provider,
models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
})
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
}),
@@ -149,39 +260,16 @@ export const layer = Layer.effect(
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID)
const model = Option.getOrUndefined(HashMap.get(record.models, modelID))
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model)
}),
update: Effect.fnUntraced(function* (providerID, modelID, fn) {
const record = yield* getRecord(providerID)
const model = produce(
HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
(draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
},
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
return pipe(
records,
HashMap.toValues,
Array.flatMap((record) => HashMap.toValues(record.models)),
Array.flatMap((record) => globalThis.Array.from(record.models.values())),
Array.map(resolve),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
@@ -217,12 +305,12 @@ export const layer = Layer.effect(
if (!record) return Option.none<ModelV2.Info>()
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano")))
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
}
const candidates = pipe(
HashMap.toValues(record.models),
globalThis.Array.from(record.models.values()),
Array.filter(
(model) =>
model.providerID === providerID &&
@@ -266,4 +354,4 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+2 -2
View File
@@ -1,3 +1,5 @@
export * as EventV2 from "./event"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location"
import { withStatics } from "./schema"
@@ -153,5 +155,3 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export * as EventV2 from "./event"
+3 -1
View File
@@ -21,7 +21,6 @@ export const Flag = {
OPENCODE_DISABLE_PRUNE: truthy("OPENCODE_DISABLE_PRUNE"),
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
@@ -59,6 +58,9 @@ export const Flag = {
get OPENCODE_PURE() {
return truthy("OPENCODE_PURE")
},
get OPENCODE_PERMISSION() {
return process.env["OPENCODE_PERMISSION"]
},
get OPENCODE_PLUGIN_META_FILE() {
return process.env["OPENCODE_PLUGIN_META_FILE"]
},
+5 -4
View File
@@ -4,9 +4,10 @@ import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
},
lookup: (ref: Location.Ref) =>
Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
),
idleTimeToLive: "5 minutes",
dependencies: [],
}) {}

Some files were not shown because too many files have changed in this diff Show More