Compare commits

...

206 Commits

Author SHA1 Message Date
opencode-agent[bot]
3f441eb39d Fix beta integration 2026-03-27 08:40:12 +00:00
opencode-agent[bot]
b223c560ea Apply PR #19347: tui plugins 2026-03-27 08:39:11 +00:00
opencode-agent[bot]
58eba9805b Apply PR #19029: feat(app): hide desktop titlebar tools behind settings 2026-03-27 08:32:03 +00:00
opencode-agent[bot]
e40fb213b4 Apply PR #18433: feat: AI SDK v6 support 2026-03-27 08:32:03 +00:00
opencode-agent[bot]
88ea386454 Apply PR #18327: refactor: replace Bun.serve with Node http.createServer in OAuth handlers 2026-03-27 08:30:51 +00:00
opencode-agent[bot]
a2eeaee853 Apply PR #17803: Remove CLI from electron app 2026-03-27 08:29:47 +00:00
opencode-agent[bot]
ef9348cc18 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-27 08:27:20 +00:00
opencode-agent[bot]
d1cd00e812 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-27 08:25:31 +00:00
opencode-agent[bot]
7c1e95a40e Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-27 08:25:31 +00:00
opencode-agent[bot]
61239ae8f9 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-27 08:25:31 +00:00
Shoubhit Dash
d341499684 fix(ui): keep partial markdown readable while responses stream (#19403) 2026-03-27 07:46:47 +00:00
Brendan Allan
eb2e6e2266 fix web ui in electron 2026-03-27 13:27:13 +08:00
Brendan Allan
f501d3cf38 build-node in prepare 2026-03-27 12:59:31 +08:00
Brendan Allan
8302d0154e electron: remove cli and use server code directly 2026-03-27 12:58:25 +08:00
Kit Langton
771525270a fix(opencode): ignore generated models snapshot files (#19362) 2026-03-27 02:21:17 +00:00
Kit Langton
e96eead32e refactor(vcs): replace async git() with ChildProcessSpawner (#19361) 2026-03-27 02:14:46 +00:00
opencode-agent[bot]
b242a8d8e4 chore: generate 2026-03-27 01:47:36 +00:00
Kit Langton
9c6f1edfd7 refactor(effect): yield services instead of promise facades (#19325) 2026-03-26 21:46:38 -04:00
Dax
c5e288c1fa Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-26 20:42:26 -04:00
Sebastian Herrlinger
4f3cacde09 update lockfile 2026-03-27 01:01:19 +01:00
Sebastian Herrlinger
4df9930fea actually skip compat check 2026-03-27 01:00:30 +01:00
Sebastian Herrlinger
4ebf459deb fix outside js owner 2026-03-27 00:59:30 +01:00
Sebastian Herrlinger
ef6d5f25a0 skip compat check for non semver 2026-03-27 00:47:37 +01:00
Sebastian Herrlinger
781ba409e7 maybe windows compat 2026-03-27 00:44:08 +01:00
Sebastian Herrlinger
4231d29930 check plugin compat 2026-03-27 00:08:33 +01:00
Sebastian Herrlinger
fcf463c3e6 refactor tests 2026-03-26 23:49:47 +01:00
Sebastian Herrlinger
b9d741fb37 renamle plug to plugin command 2026-03-26 23:43:14 +01:00
Sebastian Herrlinger
23ebfa0aee refine plugin manager dialog 2026-03-26 23:39:06 +01:00
Aiden Cline
306a1ce24e chore: bun.lock wasnt in last commit 2026-03-26 17:33:28 -05:00
Sebastian Herrlinger
b958ffa930 align title with dev 2026-03-26 23:23:10 +01:00
Dax Raad
011bccd4d8 fix types 2026-03-26 18:22:35 -04:00
Dax Raad
c55b0a1261 Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-26 18:20:04 -04:00
Luke Parker
ef7d1f7efa fix: web ui bundle build on windows (#19337) 2026-03-26 22:14:20 +00:00
Sebastian Herrlinger
7ac11a1b91 preliminary internal docs 2026-03-26 23:12:09 +01:00
Sebastian Herrlinger
baa132d563 resolve entrypoints 2026-03-26 22:33:02 +01:00
Sebastian Herrlinger
46fb6ed89f fix 2026-03-26 21:58:27 +01:00
Sebastian Herrlinger
af84d212fe source 2026-03-26 21:44:07 +01:00
Sebastian Herrlinger
f370d33273 id resolution 2026-03-26 21:44:07 +01:00
Sebastian Herrlinger
d921d7f989 v1 no enumeration 2026-03-26 21:37:00 +01:00
Sebastian Herrlinger
64aaadc97d theme tweak 2026-03-26 21:37:00 +01:00
Sebastian Herrlinger
045148b0f0 no default plugin manager keybind 2026-03-26 21:37:00 +01:00
Sebastian Herrlinger
3efd350417 title as fallback only 2026-03-26 21:37:00 +01:00
Sebastian Herrlinger
43b025037b manifest with default config 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
ede8a50f1e cleanup 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
0506b53f27 plugin manager ui 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
dde151a584 smoke id 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
9c1fc3bf4d update smoke test 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
f8249ed610 refactor 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
6df86d0c06 refactor 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
52cdd21807 meh 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
345269518e initial plugin listing 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
6e8031a962 refactor 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
1e711cda3f collapse tui api types 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
c0a4755d15 test meh 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
cd320d3a74 workspace plugin api and reactive client accessor 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
9d4b720c4f tips as plugin 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
b178de68f0 flip theme type ownership 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
ed0920b558 cleanup 2026-03-26 21:36:59 +01:00
Sebastian Herrlinger
2043b786c9 theme type 2026-03-26 21:36:58 +01:00
Sebastian Herrlinger
bb7085bbe0 extend and use api 2026-03-26 21:36:58 +01:00
Sebastian Herrlinger
ba758efb3f initial feature plugins 2026-03-26 21:36:58 +01:00
Sebastian Herrlinger
c22e63ddb3 clearer naming 2026-03-26 21:36:58 +01:00
Sebastian Herrlinger
e89f5084d3 squashed 2026-03-26 21:36:58 +01:00
Aiden Cline
b662e091ed chore: bump gitlab to v6 2026-03-26 15:33:11 -05:00
Aiden Cline
2ced45cf8a Merge branch 'dev' into ai-sdk-v6 2026-03-26 15:32:33 -05:00
Shoubhit Dash
b7a06e1939 fix(ui): reduce markdown jank while responses stream (#19304) 2026-03-26 14:43:30 -05:00
Adam
311ba4179a fix(app): remove fork session button 2026-03-26 14:34:01 -05:00
Adam
ad3b350672 fix(app): default shell tool to collapsed 2026-03-26 14:20:30 -05:00
opencode-agent[bot]
590523dcd1 chore: generate 2026-03-26 19:03:22 +00:00
Adam
b8fb75a94a fix(app): don't bundle fonts (#19329) 2026-03-26 14:02:01 -05:00
opencode-agent[bot]
98a31e30cc chore: update nix node_modules hashes 2026-03-26 18:45:12 +00:00
opencode-agent[bot]
c333e914ee chore: generate 2026-03-26 18:42:59 +00:00
Adam
c7760b433b fix(app): more startup perf (#19288) 2026-03-26 13:41:22 -05:00
Kit Langton
2e6ac8ff49 fix(mcp): close transport on failed/timed-out connections (#19200) 2026-03-26 14:41:00 -04:00
Kit Langton
1ebc92fd36 refactor(config): use cachedInvalidateWithTTL, bump effect to beta.37 (#19322) 2026-03-26 18:05:47 +00:00
opencode-agent[bot]
9f94bdb496 chore: generate 2026-03-26 17:08:33 +00:00
Kit Langton
28f5176ffd effectify Config service (#19139) 2026-03-26 13:07:01 -04:00
James Long
38450443b1 feat(core): remove workspace server, WorkspaceContext, start work towards better routing (#19316) 2026-03-26 12:30:26 -04:00
Aiden Cline
da1d37274f feat: add gpt prompt so non codex gpt models have their own system prompt modeled after codex cli (#19220) 2026-03-26 15:57:38 +00:00
opencode-agent[bot]
17e8f577d6 chore: generate 2026-03-26 15:49:53 +00:00
Kit Langton
c7d23098d1 refactor(lsp): effectify LSP service with InstanceState (#19150) 2026-03-26 11:48:36 -04:00
Dax Raad
bcf18edde4 changelog ci tweaks 2026-03-26 11:13:13 -04:00
opencode-agent[bot]
9a2482ac09 chore: generate 2026-03-26 15:05:29 +00:00
opencode
54443bfb7e release: v1.3.3 2026-03-26 15:05:21 +00:00
Dax
ec20efc11a feat: embed WebUI in binary with proxy flags (#19299)
Co-authored-by: BlankParticle <blankparticle@gmail.com>
2026-03-26 14:43:56 +00:00
Dax
83ed1c4414 tui: bypass local SSE event streaming in worker (#19183) 2026-03-26 14:41:36 +00:00
opencode-agent[bot]
1d363fa19f chore: generate 2026-03-26 14:16:07 +00:00
Shoubhit Dash
1b028d0632 revert: roll back git-backed review modes (#19295) 2026-03-26 10:14:57 -04:00
Brendan Allan
1359aa40af Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-26 16:04:20 +08:00
Aiden Cline
d500a8432a fix: ensure enterprise url is set properly during auth flow (#19212) 2026-03-25 23:59:53 -05:00
Frank
2d502d6ffe go: do not respect disabled zen models 2026-03-26 00:47:23 -04:00
Frank
2ad190e482 wip: zen 2026-03-26 00:47:23 -04:00
Frank
16742af7f3 wip: zen 2026-03-26 00:47:23 -04:00
opencode-agent[bot]
652313e036 chore: update nix node_modules hashes 2026-03-26 02:14:16 +00:00
Luke Parker
1a4a6eabe2 fix(opencode): image paste on Windows Terminal 1.25+ with kitty keyboard (#17674) 2026-03-26 12:00:38 +10:00
Aiden Cline
193ebf1f1b test: add unit test for the image case that failed in the past 2026-03-25 20:45:46 -05:00
Kit Langton
ba244a6e62 test: restore 5 workers on Windows e2e (#19188) 2026-03-25 21:28:18 -04:00
Vladimir Glafirov
7cb690d7e5 fix: bump gitlab-ai-provider to 5.3.3 for DWS tool approval support (#19185) 2026-03-26 01:17:51 +00:00
opencode-agent[bot]
31ad6e85ba chore: generate 2026-03-26 00:56:34 +00:00
Kit Langton
ea04b23745 skill: use Effect.cached for load deduplication (#19165) 2026-03-26 00:55:43 +00:00
Aiden Cline
801a045ae8 TESTING: re-enable gitlab provider despite incompat 2026-03-25 19:37:08 -05:00
Aiden Cline
05c3cfb2aa ignore: update disavowed list (#19184) 2026-03-26 00:20:43 +00:00
Kit Langton
f54e4b60cc file: use Effect.cached for scan deduplication (#19164) 2026-03-25 20:19:58 -04:00
Kit Langton
97c15a087d effectify Bus service: migrate to Effect PubSub + InstanceState (#18579) 2026-03-25 20:19:24 -04:00
Kit Langton
b90de755f9 fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration (#19042) 2026-03-25 20:15:05 -04:00
Kit Langton
8864fdce2f fix: Windows e2e stability (CrossSpawnSpawner, snapshot isolation, session race guards) (#19163) 2026-03-25 19:49:14 -04:00
Adam
5179b87aef fix(app): agent normalization (#19169) 2026-03-25 20:56:10 +00:00
Ariane Emory
66a56551be fix(task): respect agent permission config for todowrite tool (#19125) 2026-03-25 15:18:52 -05:00
Aiden Cline
a1dcc7a140 chore: upgrade ai sdk v6 deps and patch data url validation
Update AI SDK packages to the latest stable v6-compatible versions and keep integrations current. Patch @ai-sdk/provider-utils to allow inline data: URLs while preserving SSRF protections for network URLs.
2026-03-25 13:48:27 -05:00
André Cruz
7123aad5a8 fix(opencode): classify ZlibError from Bun fetch as retryable instead of unknown (#19104)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-25 13:08:40 -05:00
Adam
d6fc5f414b chore: storybook tweaks 2026-03-25 11:30:41 -05:00
Aiden Cline
77fc88c8ad chore: remove dead code for todoread tool (#19128) 2026-03-25 16:21:42 +00:00
Adam
cafc2b204b chore: cleanup 2026-03-25 11:15:34 -05:00
opencode-agent[bot]
36709aae5f chore: update nix node_modules hashes 2026-03-25 16:08:30 +00:00
opencode-agent[bot]
fac0dd8862 chore: generate 2026-03-25 15:40:53 +00:00
Shoubhit Dash
73e107250d feat: restore git-backed review modes with effectful git service (#18900) 2026-03-25 21:09:53 +05:30
Adam
b746aec493 chore: storybook tweaks 2026-03-25 10:20:19 -05:00
Aiden Cline
ad40b65b0b chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064) 2026-03-25 10:00:39 -05:00
opencode-agent[bot]
971383661a chore: generate 2026-03-25 14:48:44 +00:00
James Long
b0017bf1b9 feat(core): initial implementation of syncing (#17814) 2026-03-25 10:47:40 -04:00
Adam
0c0c6f3bdb chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam
b480a38d31 chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam
4167e25c7e fix(app): opencode web server url 2026-03-25 06:41:00 -05:00
Adam
1041ae91d1 Reapply "fix(app): startup efficiency"
This reverts commit 898456a25c.
2026-03-25 06:25:57 -05:00
Adam
898456a25c Revert "fix(app): startup efficiency" 2026-03-25 06:25:05 -05:00
Adam
53d0b58ebf fix(app): hash inline script for csp 2026-03-25 05:59:06 -05:00
Adam
2b0baf97bd Reapply "fix(app): more startup efficiency (#18985)"
This reverts commit cbe1337f24.
2026-03-25 05:59:06 -05:00
Adam
0dbfefa080 Reapply "fix(app): startup efficiency (#18854)"
This reverts commit a379eb3867.
2026-03-25 05:59:05 -05:00
Shoubhit Dash
d1c49ba210 fix(app): move message navigation off cmd+arrow (#18728) 2026-03-25 05:24:55 -05:00
Brendan Allan
3ea72aec21 app: pre-warm project globalSync state when navigate project via keybind (#19088) 2026-03-25 17:32:49 +08:00
Brendan Allan
9717383823 electron: remove file extension from electron-store wrapper (#19082) 2026-03-25 07:57:27 +00:00
Brendan Allan
5d9e780029 electron: add createDirectory to open directory picker (#19071) 2026-03-25 06:25:51 +00:00
Luke Parker
aa11fa865d fix: unblock beta conflict recovery (#19068) 2026-03-25 06:14:38 +00:00
Luke Parker
9a64bdb539 fix: beta resolver typecheck + build smoke check (#19060) 2026-03-25 05:45:30 +00:00
Aiden Cline
71693cc24b tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) 2026-03-25 00:31:29 -05:00
Jay V
fa9674edf9 feat(app): add more titlebar visibility settings 2026-03-24 18:44:29 -04:00
Aiden Cline
96646b1ba9 fix: undo changes to debug-bar 2026-03-24 17:40:50 -05:00
Aiden Cline
09a6b5fe79 Merge branch 'dev' into ai-sdk-v6 2026-03-24 17:40:23 -05:00
Jay V
f45e084b3e feat(app): hide desktop titlebar tools behind settings 2026-03-24 18:21:57 -04:00
Aiden Cline
680a2ff7c8 Merge branch 'dev' into ai-sdk-v6 2026-03-24 16:59:39 -05:00
opencode-agent[bot]
f9e35a3294 chore: update nix node_modules hashes 2026-03-24 21:53:22 +00:00
Jay V
700d0fe3cc tweak: use theme tokens for debug bar surface 2026-03-24 17:43:42 -04:00
Aiden Cline
414ae32760 Merge branch 'dev' into ai-sdk-v6 2026-03-24 16:33:50 -05:00
Aiden Cline
2f556e018d test: comment out gitlab tests for now 2026-03-24 15:56:05 -05:00
Aiden Cline
b85c0b8625 fix: comment out gitlab for now 2026-03-24 15:35:30 -05:00
Aiden Cline
6701b651d9 fix: missing import 2026-03-24 15:34:46 -05:00
Aiden Cline
8845f1a403 merge dev 2026-03-24 15:13:32 -05:00
Aiden Cline
99889e561d feat: migrate github copilot sdk to v6 2026-03-24 13:55:30 -05:00
Aiden Cline
8c2844b2ee tweak: adjust stop loop logic 2026-03-23 14:04:42 -05:00
Aiden Cline
0c519acd1f chore: rm patches now that ai sdk packages are upgraded 2026-03-23 13:15:09 -05:00
Aiden Cline
d6a2460444 fix: update stop reason to be 'other' instead of 'unknown' as per v6 changes 2026-03-23 09:09:44 -05:00
Aiden Cline
cfe0fc4ca0 fix: migrate message-v2.ts to make toModelOutput logic compliant: https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0#tooltomodeloutput-changes 2026-03-20 18:09:31 -05:00
Aiden Cline
f38b91e183 fix: finish transform.ts v6 migration 2026-03-20 17:52:57 -05:00
Aiden Cline
7088fc6c73 fix: almost fix transform.ts, add exclusions for tool-approval-request and tool-approval-response, fix type inference w/ union stuff 2026-03-20 17:49:25 -05:00
Aiden Cline
caf7d1fb5c fix: remove ts ignore for gh copilot provider, this revealed further things that need migration for copilot sdk 2026-03-20 17:02:14 -05:00
Aiden Cline
9f930bd357 fix: adjust imports in github copilot sdk to be v6 compat 2026-03-20 16:57:40 -05:00
Aiden Cline
781cc0fb6f fix: migrate structured-output.test.ts to v6 2026-03-20 16:33:35 -05:00
Aiden Cline
7644c891b4 fix prompt.ts to be v6 compat, wrap w/ try catch since type changed a bit 2026-03-20 16:25:19 -05:00
Aiden Cline
357a3fc0eb tweak: switch @ai-sdk/google-vertex key from google to vertex see: https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0#providermetadata-and-provideroptions-key 2026-03-20 16:17:53 -05:00
Aiden Cline
f881bac010 chore: add todo in llm.ts 2026-03-20 16:09:54 -05:00
Aiden Cline
1c77277179 fix: add specificationVersion to middleware in llm.ts 2026-03-20 16:07:15 -05:00
Aiden Cline
dbd72df3a4 fix: further adjust provider.ts, ensure that the typing for language is LanguageModelV3 instead of LanuageModel union (should fix compat in llm.ts) 2026-03-20 16:02:25 -05:00
Aiden Cline
d70a6b37e5 fix: azure now uses azure key instead of openai in ai sdk v6: https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0#responses-api-providermetadata-and-provideroptions-key 2026-03-20 15:36:10 -05:00
Aiden Cline
ba895b1a59 fix: resolve most prompt.ts v6 issues, need to fully resolve llm.ts before we can fix the remaining ones 2026-03-20 15:28:40 -05:00
Aiden Cline
6c2efcb8db chore: add note to message-v2 that we need to do further updates to it before this PR can be merged VERYYYY IMPORTANTgcleangclean 2026-03-20 15:27:35 -05:00
Aiden Cline
9e03d49637 fix: update compaction.ts to v6 2026-03-20 15:13:42 -05:00
Aiden Cline
44a251a406 fix: update message-v2.ts and message-v2.test.ts to v6 2026-03-20 11:42:12 -05:00
Aiden Cline
be972907a3 fix: get provider.ts type errors fixed 2026-03-20 11:18:02 -05:00
Aiden Cline
07ff24f3aa chore: bump ai gateway package 2026-03-20 11:07:33 -05:00
Dax
5792a80a8c Merge branch 'dev' into feat/auto-accept-permissions 2026-03-20 10:46:31 -04:00
Dax Raad
df44b41aaa fix: handle undefined from Npm.which in all callers
Npm.which now returns undefined instead of throwing when binary not found.
Update all callers to check for undefined and return early.
Avoid explicit union types by using a resolved variable pattern.
2026-03-20 10:33:53 -04:00
Dax Raad
db039db7f5 regen js sdk 2026-03-20 10:21:10 -04:00
Dax Raad
c1a3936b61 Merge remote-tracking branch 'origin/dev' into feat/auto-accept-permissions
# Conflicts:
#	packages/sdk/js/src/v2/gen/types.gen.ts
2026-03-20 10:20:26 -04:00
Dax
b1d537b49a Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-20 10:02:14 -04:00
Dax
9f5713e802 Merge branch 'dev' into refactor/oauth-node-http 2026-03-20 10:02:12 -04:00
Aiden Cline
6c6868997a chore: run v6 codemod 2026-03-20 00:12:30 -05:00
Aiden Cline
a7dda4c108 chore: bump packages to v6 2026-03-19 23:32:40 -05:00
Dax Raad
bc43bf378d sync 2026-03-19 23:38:54 -04:00
Dax Raad
3b669e7f2b fix type 2026-03-19 23:37:00 -04:00
Dax
e48da96886 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:32:30 -04:00
Dax
20249bb723 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:29:18 -04:00
Dax Raad
205affa0eb fix: type error in mcp env and add missing @npmcli/arborist dependency 2026-03-19 23:09:27 -04:00
Dax Raad
7409ce8aec fix: remove BUN_BE_BUN workaround, use node for ESLint server
Replace tsx dependency with explicit 'node' invocation for the
compiled ESLint server. Remove all BUN_BE_BUN env var references
which are no longer needed after the Bun-to-Node migration.
2026-03-19 22:05:30 -04:00
Dax Raad
c075a5d694 fix: force reinstall in which() when .bin is missing
When the lockfile exists but .bin is empty or absent, add() would
read the lockfile via loadVirtual() and return early without calling
reify(). Delete the lockfile before calling add() so it proceeds
with a full install.
2026-03-19 21:54:53 -04:00
Dax Raad
34c676ba7a fix: scope Npm.add() lock to per-package key
Use npm-install:${pkg} instead of a global npm-install lock so
concurrent installs of different packages can run in parallel.
2026-03-19 21:49:22 -04:00
Dax Raad
816a5f793f refactor: replace Bun.serve with Node http.createServer in OAuth handlers
Use Node's createServer for MCP OAuth callback and Codex plugin OAuth
servers instead of Bun.serve, making them work under Node.js.
2026-03-19 21:47:38 -04:00
Dax Raad
ca2099e69d refactor: replace BunProc with Npm module using @npmcli/arborist 2026-03-19 20:17:03 -04:00
MakonnenMak
1392d868b1 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-13 18:10:02 -04:00
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
David Hill
611e616010 tui: add right margin to question progress indicator so it doesn't touch the container edge 2026-03-02 14:27:43 +00:00
David Hill
b286c0ae3f tweak(ui): restore questions progress indicator 2026-03-02 12:08:37 +00:00
David Hill
81a61f8dbd tweak(ui): improve collapse area 2026-03-02 11:14:01 +00:00
David Hill
752e449e38 tweak(ui): improve collpase area 2026-03-02 11:11:52 +00:00
David Hill
5d419a0211 tweak(ui): expand question dock toggle area 2026-02-27 21:30:49 +00:00
David Hill
8b168981aa tweak(ui): active state on type your own answer 2026-02-27 18:50:50 +00:00
David Hill
724dd665ec tweak(ui): collapse questions 2026-02-27 18:47:53 +00:00
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
484 changed files with 26803 additions and 10630 deletions

1
.github/VOUCHED.td vendored
View File

@@ -25,3 +25,4 @@ r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-OpenCodeEngineer bot that spams issues

View File

@@ -7,15 +7,17 @@ create UPCOMING_CHANGELOG.md
it should have sections
```
# TUI
## TUI
# Desktop
## Desktop
# Core
## Core
# Misc
## Misc
```
go through each PR merged since the last tag
fetch the latest github release for this repository to determine the last release version.
find each PR that was merged since the last release
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.

View File

@@ -0,0 +1,223 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"nord0": "#2E3440",
"nord1": "#3B4252",
"nord2": "#434C5E",
"nord3": "#4C566A",
"nord4": "#D8DEE9",
"nord5": "#E5E9F0",
"nord6": "#ECEFF4",
"nord7": "#8FBCBB",
"nord8": "#88C0D0",
"nord9": "#81A1C1",
"nord10": "#5E81AC",
"nord11": "#BF616A",
"nord12": "#D08770",
"nord13": "#EBCB8B",
"nord14": "#A3BE8C",
"nord15": "#B48EAD"
},
"theme": {
"primary": {
"dark": "nord10",
"light": "nord9"
},
"secondary": {
"dark": "nord9",
"light": "nord9"
},
"accent": {
"dark": "nord7",
"light": "nord7"
},
"error": {
"dark": "nord11",
"light": "nord11"
},
"warning": {
"dark": "nord12",
"light": "nord12"
},
"success": {
"dark": "nord14",
"light": "nord14"
},
"info": {
"dark": "nord8",
"light": "nord10"
},
"text": {
"dark": "nord6",
"light": "nord0"
},
"textMuted": {
"dark": "#8B95A7",
"light": "nord1"
},
"background": {
"dark": "nord0",
"light": "nord6"
},
"backgroundPanel": {
"dark": "nord1",
"light": "nord5"
},
"backgroundElement": {
"dark": "nord2",
"light": "nord4"
},
"border": {
"dark": "nord2",
"light": "nord3"
},
"borderActive": {
"dark": "nord3",
"light": "nord2"
},
"borderSubtle": {
"dark": "nord2",
"light": "nord3"
},
"diffAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffContext": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHighlightAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffHighlightRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffAddedBg": {
"dark": "#36413C",
"light": "#E6EBE7"
},
"diffRemovedBg": {
"dark": "#43393D",
"light": "#ECE6E8"
},
"diffContextBg": {
"dark": "nord1",
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#303A35",
"light": "#DDE4DF"
},
"diffRemovedLineNumberBg": {
"dark": "#3C3336",
"light": "#E4DDE0"
},
"markdownText": {
"dark": "nord4",
"light": "nord0"
},
"markdownHeading": {
"dark": "nord8",
"light": "nord10"
},
"markdownLink": {
"dark": "nord9",
"light": "nord9"
},
"markdownLinkText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCode": {
"dark": "nord14",
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownEmph": {
"dark": "nord12",
"light": "nord12"
},
"markdownStrong": {
"dark": "nord13",
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownListItem": {
"dark": "nord8",
"light": "nord10"
},
"markdownListEnumeration": {
"dark": "nord7",
"light": "nord7"
},
"markdownImage": {
"dark": "nord9",
"light": "nord9"
},
"markdownImageText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCodeBlock": {
"dark": "nord4",
"light": "nord0"
},
"syntaxComment": {
"dark": "#8B95A7",
"light": "nord3"
},
"syntaxKeyword": {
"dark": "nord9",
"light": "nord9"
},
"syntaxFunction": {
"dark": "nord8",
"light": "nord8"
},
"syntaxVariable": {
"dark": "nord7",
"light": "nord7"
},
"syntaxString": {
"dark": "nord14",
"light": "nord14"
},
"syntaxNumber": {
"dark": "nord15",
"light": "nord15"
},
"syntaxType": {
"dark": "nord7",
"light": "nord7"
},
"syntaxOperator": {
"dark": "nord9",
"light": "nord9"
},
"syntaxPunctuation": {
"dark": "nord4",
"light": "nord0"
}
}
}

View File

@@ -0,0 +1,852 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
const look = (map: Record<string, unknown>) => {
return {
panel: ink(map, "backgroundPanel", ui.panel),
border: ink(map, "border", ui.border),
text: ink(map, "text", ui.text),
muted: ink(map, "textMuted", ui.muted),
accent: ink(map, "primary", ui.accent),
selected: ink(map, "selectedListItemText", ui.text),
}
}
const tone = (api: TuiPluginApi) => {
return look(api.theme.current)
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiPluginApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiPluginApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiPluginApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiPluginApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiPluginApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginMeta
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
params?: Record<string, unknown>
}) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const home = (input: Cfg): TuiSlotPlugin => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
const skin = look(map)
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const fill = [
skin.accent,
skin.muted,
ink(map, "info", ui.accent),
skin.text,
ink(map, "success", ui.accent),
ink(map, "warning", ui.accent),
ink(map, "secondary", ui.accent),
ink(map, "error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={fill[i]}>{line}</text>
))}
</box>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
</text>
</box>
</box>
)
},
},
})
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
order,
slots: {
sidebar_content(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<text fg={skin.accent}>
<b>{title}</b>
</text>
<text fg={skin.text}>{text}</text>
<text fg={skin.muted}>
{input.label} order {order} · session {value.session_id.slice(0, 8)}
</text>
</box>
)
},
},
})
const slot = (input: Cfg): TuiSlotPlugin[] => [
home(input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
]
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
api.lifecycle.onDispose(() => {
api.renderer.removePostProcessFn(post)
})
api.route.register([
{
name: route.screen,
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
},
{
name: route.modal,
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(api, value, keys)
for (const item of slot(value)) {
api.slots.register(item)
}
}
export default {
id: "tui-smoke",
tui,
}

1
.opencode/themes/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
smoke-theme.json

19
.opencode/tui.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": true,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}

600
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
"x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
"aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
"aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
"x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
}
}

View File

@@ -25,7 +25,7 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@effect/platform-node": "4.0.0-beta.37",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -45,14 +45,15 @@
"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.35",
"ai": "5.0.124",
"effect": "4.0.0-beta.37",
"ai": "6.0.138",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -112,8 +113,7 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch"
}
}

View File

@@ -465,10 +465,13 @@ export async function waitSession(page: Page, input: { directory: string; sessio
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false
if (!input.sessionID && current) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && state?.sessionID) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false

View File

@@ -19,7 +19,8 @@ export const promptVariantSelector = '[data-component="prompt-variant-control"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'
export const settingsFontSelector = '[data-action="settings-font"]'
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'

View File

@@ -93,7 +93,7 @@ async function todoDock(page: any, sessionID: string) {
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input) => {
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
@@ -118,7 +118,7 @@ async function todoDock(page: any, sessionID: string) {
}
const read = () =>
page.evaluate((sessionID) => {
page.evaluate((sessionID: string) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
@@ -186,6 +186,8 @@ async function withMockPermission<T>(
opts: { child?: any } | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) {
const listUrl = /\/permission(?:\?.*)?$/
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
let pending = [
{
...request,
@@ -204,7 +206,8 @@ async function withMockPermission<T>(
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").pop()
const parts = url.pathname.split("/").filter(Boolean)
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
@@ -213,8 +216,10 @@ async function withMockPermission<T>(
})
}
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
await page.route(listUrl, list)
for (const item of replyUrls) {
await page.route(item, reply)
}
const sessionList = opts?.child
? async (route: any) => {
@@ -242,8 +247,10 @@ async function withMockPermission<T>(
try {
return await fn(state)
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
await page.unroute(listUrl, list)
for (const item of replyUrls) {
await page.unroute(item, reply)
}
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}

View File

@@ -28,7 +28,17 @@ type Footer = {
type Probe = {
dir?: string
sessionID?: string
model?: { providerID: string; modelID: string }
agent?: string
model?: { providerID: string; modelID: string; name?: string }
variant?: string | null
pick?: {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string | null
}
variants?: string[]
models?: Array<{ providerID: string; modelID: string; name: string }>
agents?: Array<{ name: string }>
}
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -50,6 +60,86 @@ async function probe(page: Page): Promise<Probe | null> {
})
}
async function currentModel(page: Page) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
const value = await probe(page).then(modelKey)
if (!value) throw new Error("Failed to resolve current model key")
return value
}
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
await expect
.poll(
() =>
page.evaluate((key) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: Record<string, unknown>
}
}
}
return !!win.__opencode_e2e?.model?.controls?.[key]
}, key),
{ timeout: 30_000 },
)
.toBe(true)
}
async function pickAgent(page: Page, value: string) {
await waitControl(page, "setAgent")
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setAgent?: (value: string | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setAgent
if (!fn) throw new Error("Model e2e agent control is not enabled")
fn(value)
}, value)
}
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
await waitControl(page, "setModel")
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setModel
if (!fn) throw new Error("Model e2e model control is not enabled")
fn(value)
}, value)
}
async function pickVariant(page: Page, value: string) {
await waitControl(page, "setVariant")
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setVariant?: (value: string | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setVariant
if (!fn) throw new Error("Model e2e variant control is not enabled")
fn(value)
}, value)
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -82,31 +172,15 @@ async function waitModel(page: Page, value: string) {
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const item = page
.locator('[data-slot="select-select-item"]')
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
.first()
await expect(item).toBeVisible()
await item.click()
await pickAgent(page, value)
}
async function variantCount(page: Page) {
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const count = await page.locator('[data-slot="select-select-item"]').count()
await page.keyboard.press("Escape")
return count
return (await probe(page))?.variants?.length ?? 0
}
async function agents(page: Page) {
const select = page.locator(promptAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
await page.keyboard.press("Escape")
return labels.map((item) => item.trim()).filter(Boolean)
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
@@ -132,48 +206,23 @@ async function ensureVariant(page: Page, directory: string): Promise<Footer> {
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
if (!next) throw new Error("Current model has no alternate variant to select")
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
if (count < 2) throw new Error("Current model has no alternate variant to select")
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
if (!next || next === current.variant) continue
await item.click()
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
throw new Error("Failed to choose a different variant")
await pickVariant(page, next)
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
async function chooseOtherModel(page: Page): Promise<Footer> {
const current = await read(page)
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
await expect(button).toBeVisible()
await button.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const items = dialog.locator('[data-slot="list-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const selected = (await item.getAttribute("data-selected")) === "true"
if (selected) continue
await item.click()
await expect(dialog).toHaveCount(0)
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
return read(page)
}
throw new Error("Failed to choose a different model")
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
const current = await currentModel(page)
const next = (await probe(page))?.models?.find((item) => {
const key = `${item.providerID}:${item.modelID}`
return key !== current && !skip.includes(key)
})
if (!next) throw new Error("Failed to choose a different model")
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
return read(page)
}
async function goto(page: Page, directory: string, sessionID?: string) {
@@ -249,17 +298,14 @@ async function newWorkspaceSession(page: Page, slug: string) {
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
test("session model and variant restore per session without leaking into new sessions", async ({
page,
withProject,
}) => {
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const firstState = await chooseDifferentVariant(page)
const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
@@ -269,10 +315,10 @@ test("session model and variant restore per session without leaking into new ses
await waitFooter(page, firstState)
await gotoSession()
const fresh = await ensureVariant(page, directory)
expect(fresh.variant).not.toBe(firstState.variant)
const fresh = await read(page)
expect(fresh.model).not.toBe(firstState.model)
const secondState = await chooseOtherModel(page)
const secondState = await chooseOtherModel(page, [firstKey])
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
@@ -294,8 +340,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
await ensureVariant(page, root)
const firstState = await chooseDifferentVariant(page)
const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
@@ -307,7 +353,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page)
const secondState = await chooseOtherModel(page, [firstKey])
const secondKey = await currentModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
@@ -316,8 +363,7 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
await ensureVariant(page, twoDir)
const thirdState = await chooseDifferentVariant(page)
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)

View File

@@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import {
settingsColorSchemeSelector,
settingsFontSelector,
settingsCodeFontSelector,
settingsLanguageSelectSelector,
settingsNotificationsAgentSelector,
settingsNotificationsErrorsSelector,
@@ -12,6 +12,7 @@ import {
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUIFontSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
@@ -152,74 +153,28 @@ test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
.toBeNull()
})
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsFontSelector)
await expect(select).toBeVisible()
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const initialUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(initialFontFamily).toContain("IBM Plex Mono")
await select.locator('[data-slot="select-select-trigger"]').click()
const next = "Test Mono"
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
await page.waitForTimeout(100)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
const newFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(newFontFamily).not.toBe(initialFontFamily)
})
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const fontSelect = dialog.locator(settingsFontSelector)
await expect(fontSelect).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const currentFont =
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
const fontItems = page.locator('[data-slot="select-select-item"]')
expect(await fontItems.count()).toBeGreaterThan(1)
if (currentFont) {
await fontItems.filter({ hasNotText: currentFont }).first().click()
}
if (!currentFont) {
await fontItems.nth(1).click()
}
await input.click()
await input.clear()
await input.pressSequentially(next)
await expect(input).toHaveValue(next)
await expect
.poll(async () => {
@@ -230,7 +185,218 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
})
.toMatchObject({
appearance: {
font: expect.any(String),
font: next,
},
})
const newFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const newUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(newFontFamily).toContain(next)
expect(newFontFamily).not.toBe(initialFontFamily)
expect(newUIFamily).toBe(initialUIFamily)
})
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "Inter")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
const initialCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(initialFontFamily).toContain("Inter")
const next = "Test Sans"
await input.click()
await input.clear()
await input.pressSequentially(next)
await expect(input).toHaveValue(next)
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
uiFont: next,
},
})
const newFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
const newCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(newFontFamily).toContain(next)
expect(newFontFamily).not.toBe(initialFontFamily)
expect(newCodeFamily).toBe(initialCodeFamily)
})
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await input.click()
await input.clear()
await input.pressSequentially("Reset Mono")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: "Reset Mono",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(fontFamily).toContain("IBM Plex Mono")
expect(fontFamily).not.toContain("Reset Mono")
})
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await input.click()
await input.clear()
await input.pressSequentially("Reset Sans")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
uiFont: "Reset Sans",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
uiFont: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(fontFamily).toContain("Inter")
expect(fontFamily).not.toContain("Reset Sans")
})
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const code = dialog.locator(settingsCodeFontSelector)
const ui = dialog.locator(settingsUIFontSelector)
await expect(code).toBeVisible()
await expect(ui).toBeVisible()
const initialMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const initialSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
await code.click()
await code.clear()
await code.pressSequentially(mono)
await expect(code).toHaveValue(mono)
await ui.click()
await ui.clear()
await ui.pressSequentially(sans)
await expect(ui).toHaveValue(sans)
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
},
})
@@ -239,11 +405,18 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
return raw ? JSON.parse(raw) : null
}, settingsKey)
const updatedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(updatedFontFamily).not.toBe(initialFontFamily)
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
const updatedMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const updatedSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(updatedMono).toContain(mono)
expect(updatedMono).not.toBe(initialMono)
expect(updatedSans).toContain(sans)
expect(updatedSans).not.toBe(initialSans)
expect(updatedSettings?.appearance?.font).toBe(mono)
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
await closeDialog(page, dialog)
await page.reload()
@@ -259,7 +432,8 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
})
.toMatchObject({
appearance: {
font: updatedSettings?.appearance?.font,
font: mono,
uiFont: sans,
},
})
@@ -270,17 +444,32 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
await expect
.poll(async () => {
return await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
return await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
})
.not.toBe(initialFontFamily)
.toContain(mono)
const rehydratedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
await expect
.poll(async () => {
return await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
})
.toContain(sans)
const rehydratedMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const rehydratedSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(rehydratedMono).toContain(mono)
expect(rehydratedMono).not.toBe(initialMono)
expect(rehydratedSans).toContain(sans)
expect(rehydratedSans).not.toBe(initialSans)
expect(rehydratedSettings?.appearance?.font).toBe(mono)
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.2",
"version": "1.3.3",
"description": "",
"type": "module",
"exports": {

View File

@@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,12 +32,11 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
@@ -77,11 +76,6 @@ declare global {
}
}
function MarkedProviderWithNativeParser(props: ParentProps) {
const platform = usePlatform()
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
@@ -130,7 +124,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
export function AppBaseProviders(props: ParentProps) {
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
<Font />
@@ -139,14 +133,14 @@ export function AppBaseProviders(props: ParentProps) {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider>
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>
<DialogProvider>
<MarkedProviderWithNativeParser>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
</ErrorBoundary>

View File

@@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,19 +9,26 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { DialogSelectProvider } from "./dialog-select-provider"
import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const providers = useProviders()
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -33,16 +40,30 @@ export function DialogConnectProvider(props: { provider: string }) {
timer.current = undefined
})
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const methods = createMemo(
const provider = createMemo(
() =>
globalSync.data.provider_auth[props.provider] ?? [
{
type: "api",
label: language.t("provider.connect.method.apiKey"),
},
],
providers.all().find((x) => x.id === props.provider) ??
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +198,11 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
@@ -296,8 +321,12 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
onMount(() => {
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
selectMethod(0)
}
})
@@ -315,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
function goBack() {
if (methods().length === 1) {
dialog.show(() => <DialogSelectProvider />)
all()
return
}
if (store.authorization) {
@@ -326,7 +355,7 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.reset" })
return
}
dialog.show(() => <DialogSelectProvider />)
all()
}
function MethodSelection() {
@@ -573,6 +602,14 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>

View File

@@ -1,10 +1,12 @@
import { useMutation } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { Component, createEffect, createMemo, on, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
const statusLabels = {
@@ -18,6 +20,48 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const [state, setState] = createStore({
done: false,
loading: false,
})
createEffect(
on(
() => sync.data.mcp_ready,
(ready, prev) => {
if (!ready && prev) setState("done", false)
},
{ defer: true },
),
)
createEffect(() => {
if (state.done || state.loading) return
if (sync.data.mcp_ready) {
setState("done", true)
return
}
setState("loading", true)
void sdk.client.mcp
.status()
.then((result) => {
sync.set("mcp", result.data ?? {})
sync.set("mcp_ready", true)
setState("done", true)
})
.catch((err) => {
setState("done", true)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
.finally(() => {
setState("loading", false)
})
})
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})

View File

@@ -8,8 +8,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
@@ -21,6 +19,18 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
const providers = useProviders()
const language = useLanguage()
const connect = (provider: string) => {
void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
})
}
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
}
let listRef: ListRef | undefined
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return
@@ -91,7 +101,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
}}
onSelect={(x) => {
if (!x) return
dialog.show(() => <DialogConnectProvider provider={x.id} />)
connect(x.id)
}}
>
{(i) => (
@@ -122,9 +132,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
onClick={all}
>
{language.t("dialog.provider.viewAll")}
</Button>

View File

@@ -10,8 +10,6 @@ import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
@@ -107,12 +105,16 @@ export function ModelSelectorPopover(props: {
const handleManage = () => {
setStore("open", false)
dialog.show(() => <DialogManageModels />)
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
const handleConnectProvider = () => {
setStore("open", false)
dialog.show(() => <DialogSelectProvider />)
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
}
const language = useLanguage()
@@ -193,26 +195,29 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const dialog = useDialog()
const language = useLanguage()
const provider = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
}
const manage = () => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
return (
<Dialog
title={language.t("dialog.model.select.title")}
action={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => dialog.show(() => <DialogSelectProvider />)}
>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
{language.t("command.provider.connect")}
</Button>
}
>
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"
onClick={() => dialog.show(() => <DialogManageModels />)}
>
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")}
</Button>
</Dialog>

View File

@@ -27,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
@@ -572,6 +571,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent()
const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))
@@ -1493,7 +1493,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon

View File

@@ -7,6 +7,7 @@ import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -32,6 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const providers = useProviders()
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button")
@@ -50,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -12,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@@ -92,6 +93,7 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const sync = useSync()
const language = useLanguage()
const providers = useProviders()
const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
@@ -130,7 +132,7 @@ export function SessionContextTab() {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))

View File

@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
@@ -134,6 +135,7 @@ export function SessionHeader() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -151,6 +153,10 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const search = createMemo(() => platform.platform !== "desktop" || settings.general.showSearch())
const tree = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
const term = createMemo(() => platform.platform !== "desktop" || settings.general.showTerminal())
const status = createMemo(() => platform.platform !== "desktop" || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -267,35 +273,37 @@ export function SessionHeader() {
return (
<>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={search()}>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
</Show>
</Show>
<Show when={rightMount()}>
{(mount) => (
@@ -415,24 +423,28 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
@@ -451,30 +463,32 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>

View File

@@ -1,27 +1,43 @@
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
@@ -29,12 +45,19 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined
}
const playDemoSound = (src: string | undefined) => {
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!src) return
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src)
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
@@ -44,11 +67,16 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
@@ -104,9 +132,7 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo(() =>
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
@@ -126,25 +152,10 @@ export const SettingsGeneral: Component = () => {
})),
)
const fontOptions = [
{ value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
{ value: "cascadia-code", label: "font.option.cascadiaCode" },
{ value: "fira-code", label: "font.option.firaCode" },
{ value: "hack", label: "font.option.hack" },
{ value: "inconsolata", label: "font.option.inconsolata" },
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
{ value: "iosevka", label: "font.option.iosevka" },
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
{ value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
{ value: "geist-mono", label: "font.option.geistMono" },
] as const
const fontOptionsList = [...fontOptions]
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const soundSelectProps = (
enabled: () => boolean,
@@ -158,7 +169,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.src)
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
@@ -169,7 +180,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
playDemoSound(option.src)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
@@ -253,6 +264,74 @@ export const SettingsGeneral: Component = () => {
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
@@ -311,28 +390,50 @@ export const SettingsGeneral: Component = () => {
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-ui-font"
label={language.t("settings.general.row.uiFont.title")}
hideLabel
type="text"
value={sans()}
onChange={(value) => settings.appearance.setUIFont(value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
{option ? language.t(option.label) : ""}
</span>
)}
</Select>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-code-font"
label={language.t("settings.general.row.font.title")}
hideLabel
type="text"
value={mono()}
onChange={(value) => settings.appearance.setFont(value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
@@ -561,6 +662,10 @@ export const SettingsGeneral: Component = () => {
)
}}
</Show>
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</div>
)

View File

@@ -0,0 +1,445 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
const listServersByHealth = (
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
url: undefined as string | undefined,
tick: 0,
})
createEffect(() => {
state.tick
let dead = false
const result = get?.()
if (!result) {
setState("url", undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("url", next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
})
return
}
setState("url", normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
})
return {
key: () => {
const u = state.url
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setState("tick", (value) => value + 1),
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const sdk = useSDK()
const [load, setLoad] = createStore({
lspDone: false,
lspLoading: false,
mcpDone: false,
mcpLoading: false,
})
const fail = (err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
createEffect(() => {
if (!props.shown()) return
if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
setLoad("mcpLoading", true)
void sdk.client.mcp
.status()
.then((result) => {
sync.set("mcp", result.data ?? {})
sync.set("mcp_ready", true)
})
.catch((err) => {
setLoad("mcpDone", true)
fail(err)
})
.finally(() => {
setLoad("mcpLoading", false)
})
}
if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
setLoad("lspLoading", true)
void sdk.client.lsp
.status()
.then((result) => {
sync.set("lsp", result.data ?? [])
sync.set("lsp_ready", true)
})
.catch((err) => {
setLoad("lspDone", true)
fail(err)
})
.finally(() => {
setLoad("lspLoading", false)
})
}
})
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() =>
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpNames().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/>
</div>
</button>
)
}}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={lspItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
}
>
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
)
}

View File

@@ -1,193 +1,24 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
const listServersByHealth = (
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
url: undefined as string | undefined,
tick: 0,
})
createEffect(() => {
state.tick
let dead = false
const result = get?.()
if (!result) {
setState("url", undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("url", next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
})
return
}
setState("url", normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
})
return {
key: () => {
const u = state.url
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setState("tick", (value) => value + 1),
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
}
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
export function StatusPopover() {
const sync = useSync()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const server = useServer()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const healthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpNames().some((name) => {
const status = mcpStatus(name)
return status !== "connected" && status !== "disabled"
})
return serverHealthy && !anyMcpIssue
const mcp = Object.values(sync.data.mcp ?? {})
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
return serverHealthy && !issue
})
return (
@@ -209,9 +40,9 @@ export function StatusPopover() {
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
}}
/>
</div>
@@ -221,199 +52,15 @@ export function StatusPopover() {
placement="bottom-end"
shift={-168}
>
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
<Show when={shown()}>
<Suspense
fallback={
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !isBlocked(),
"cursor-not-allowed": isBlocked(),
}}
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
dimmed={isBlocked()}
status={health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
>
{language.t("status.popover.action.manageServers")}
</Button>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpNames().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{language.t("dialog.mcp.empty")}
</div>
}
>
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/>
</div>
</button>
)
}}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={lspItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{language.t("dialog.lsp.empty")}
</div>
}
>
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
<Body shown={shown} />
</Suspense>
</Show>
</Popover>
)
}

View File

@@ -1,4 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

View File

@@ -5,12 +5,13 @@ 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"
import { useTheme } from "@opencode-ai/ui/theme/context"
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 { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
@@ -40,6 +41,7 @@ export function Titlebar() {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const navigate = useNavigate()
const location = useLocation()
@@ -78,6 +80,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => platform.platform !== "desktop" || settings.general.showNavigation())
const back = () => {
const next = backPath(history)
@@ -252,7 +255,7 @@ export function Titlebar() {
</div>
</div>
</Show>
<Show when={hasProjects()}>
<Show when={hasProjects() && nav()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
@@ -287,6 +290,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
})
test("matchKeybind supports bracket keys", () => {
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
const prev = keybinds[0]
const next = keybinds[1]
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
),
).toBe(true)
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")

View File

@@ -9,23 +9,13 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
getOwner,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
} from "solid-js"
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
@@ -80,6 +70,8 @@ function createGlobalSync() {
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
onCleanup(() => {
active = false
@@ -162,6 +154,7 @@ function createGlobalSync() {
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearProviderRev(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
@@ -258,6 +251,12 @@ function createGlobalSync() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
global: {
config: globalStore.config,
path: globalStore.path,
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
store: child[0],
setStore: child[1],
@@ -278,15 +277,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: queue.refresh,
refresh: () => {
if (recent) return
queue.refresh()
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
@@ -309,7 +313,10 @@ function createGlobalSync() {
loadLsp: () => {
sdkFor(directory)
.lsp.status()
.then((x) => setStore("lsp", x.data ?? []))
.then((x) => {
setStore("lsp", x.data ?? [])
setStore("lsp_ready", true)
})
},
})
})
@@ -325,17 +332,19 @@ function createGlobalSync() {
})
async function bootstrap() {
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootingRoot = true
try {
await bootstrapGlobal({
globalSDK: globalSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootedAt = Date.now()
} finally {
bootingRoot = false
}
}
onMount(() => {
@@ -392,13 +401,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return (
<Switch>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {

View File

@@ -7,6 +7,7 @@ import type {
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Session,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
@@ -15,7 +16,7 @@ import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
type GlobalStore = {
@@ -31,73 +32,108 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
})
})
}
function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
}
const providerRev = new Map<string, number>()
export function clearProviderRev(directory: string) {
providerRev.delete(directory)
}
function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const health = await input.globalSDK.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
showToast({
variant: "error",
title: input.connectErrorTitle,
description: input.connectErrorDescription,
})
input.setGlobalStore("ready", true)
return
}
const tasks = [
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
]
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) {
const message = formatServerError(errors[0], input.translate)
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
showToast({
variant: "error",
title: input.requestFailedTitle,
description: message + more,
})
}
const slow = [
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
]
showErrors({
errors: errors(await runAll(fast)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
await waitForPaint()
showErrors({
errors: errors(await runAll(slow)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
input.setGlobalStore("ready", true)
}
@@ -111,6 +147,44 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
setStore("session", (list) => {
const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id)
if (idx === -1) return [...next, session]
if (next[idx]?.id === session.id) {
next[idx] = session
return next
}
next.splice(idx, 0, session)
return next
})
}
function warmSessions(input: {
ids: string[]
store: Store<State>
setStore: SetStoreFunction<State>
sdk: OpencodeClient
}) {
const known = new Set(input.store.session.map((item) => item.id))
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
if (ids.length === 0) return Promise.resolve()
return Promise.all(
ids.map((sessionID) =>
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
const session = x.data
if (!session?.id) return
mergeSession(input.setStore, session)
}),
),
).then(() => undefined)
}
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
@@ -119,88 +193,166 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
}) {
if (input.store.status !== "complete") input.setStore("status", "loading")
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
global: {
config: Config
path: Path
project: Project[]
provider: ProviderListResponse
}
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
if (seededProject) input.setStore("project", seededProject)
if (seededPath) input.setStore("path", seededPath)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
}
if (loading || input.store.provider.all.length === 0) {
input.setStore("provider_ready", false)
}
input.setStore("mcp_ready", false)
input.setStore("mcp", {})
input.setStore("lsp_ready", false)
input.setStore("lsp", [])
if (loading) input.setStore("status", "partial")
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const fast = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
seededPath
? Promise.resolve()
: retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
}),
)
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
}),
)
}),
),
]
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
retry(() =>
input.sdk.mcp.status().then((x) => {
input.setStore("mcp", x.data!)
input.setStore("mcp_ready", true)
}),
),
]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
description: formatServerError(errs[0], input.translate),
})
input.setStore("status", "partial")
return
}
if (input.store.status !== "complete") input.setStore("status", "partial")
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
void retry(() => input.sdk.provider.list())
.then((x) => {
if (providerRev.get(input.directory) !== rev) return
input.setStore("provider", normalizeProviderList(x.data!))
input.setStore("provider_ready", true)
})
.catch((err) => {
if (providerRev.get(input.directory) !== rev) return
console.error("Failed to refresh provider list", err)
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
})
}

View File

@@ -160,6 +160,7 @@ export function createChildStoreManager(input: {
project: "",
projectMeta: initialMeta,
icon: initialIcon,
provider_ready: false,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -173,7 +174,9 @@ export function createChildStoreManager(input: {
todo: {},
permission: {},
question: {},
mcp_ready: false,
mcp: {},
lsp_ready: false,
lsp: [],
vcs: vcsStore.value,
limit: 5,

View File

@@ -38,6 +38,7 @@ export type State = {
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider_ready: boolean
provider: ProviderListResponse
config: Config
path: Path
@@ -58,9 +59,11 @@ export type State = {
question: {
[sessionID: string]: QuestionRequest[]
}
mcp_ready: boolean
mcp: {
[name: string]: McpStatus
}
lsp_ready: boolean
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
name,
mode: "primary",
permission: {},
options: {},
}) as Agent
describe("normalizeAgentList", () => {
test("keeps array payloads", () => {
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
})
test("wraps a single agent payload", () => {
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
})
test("extracts agents from keyed objects", () => {
expect(
normalizeAgentList({
build: agent("build"),
docs: agent("docs"),
}),
).toEqual([agent("build"), agent("docs")])
})
test("drops invalid payloads", () => {
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})

View File

@@ -1,7 +1,21 @@
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function isAgent(input: unknown): input is Agent {
if (!input || typeof input !== "object") return false
const item = input as { name?: unknown; mode?: unknown }
if (typeof item.name !== "string") return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
}
export function normalizeAgentList(input: unknown): Agent[] {
if (Array.isArray(input)) return input.filter(isAgent)
if (isAgent(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,

View File

@@ -1,42 +1,10 @@
import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
import { dict as ko } from "@/i18n/ko"
import { dict as de } from "@/i18n/de"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
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 ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale =
| "en"
@@ -59,6 +27,7 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
}
const base = i18n.flatten({ ...en, ...uiEn })
const DICT: Record<Locale, Dictionary> = {
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
const dicts = new Map<Locale, Dictionary>([["en", base]])
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
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")),
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")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
}
function loadDict(locale: Locale) {
const hit = dicts.get(locale)
if (hit) return Promise.resolve(hit)
if (locale === "en") return Promise.resolve(base)
const load = loaders[locale]
return load().then((next: Dictionary) => {
dicts.set(locale, next)
return next
})
}
export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
tr,
}
void PARITY_CHECK
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
return "en"
}
function normalizeLocale(value: string): Locale {
export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
} catch {
return
}
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
init: () => {
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
locale: detectLocale() as Locale,
locale: initial,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])
const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(dict, i18n.resolveTemplate)
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const label = (value: Locale) => t(LABEL_KEY[value])

View File

@@ -390,10 +390,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
if (modelEnabled()) {
const probe = Symbol("model-probe")
modelProbe.bind(probe, {
setAgent: agent.set,
setModel: model.set,
setVariant: model.variant.set,
})
createEffect(() => {
const agent = result.agent.current()
const model = result.model.current()
modelProbe.set({
modelProbe.set(probe, {
dir: sdk.directory,
sessionID: id(),
last: store.last,
@@ -411,10 +419,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
pick: scope(),
base: undefined,
current: store.current,
variants: result.model.variant.list(),
models: result.model
.list()
.filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
.map((item) => ({
providerID: item.provider.id,
modelID: item.id,
name: item.name,
})),
agents: result.agent.list().map((item) => ({ name: item.name })),
})
})
onCleanup(() => modelProbe.clear())
onCleanup(() => modelProbe.clear(probe))
}
return result

View File

@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
import { playSoundById } from "@/utils/sound"
type NotificationBase = {
directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent()))
void playSoundById(settings.sounds.agent())
}
append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors()))
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined

View File

@@ -23,6 +23,11 @@ export interface Settings {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showTerminal: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -33,6 +38,7 @@ export interface Settings {
appearance: {
fontSize: number
font: string
uiFont: string
}
keybinds: Record<string, string>
permissions: {
@@ -42,13 +48,61 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
const monoKey = "ibm-plex-mono"
function input(font: string | undefined, key?: string) {
if (!font || font === key || !font.trim()) return ""
return font
}
function family(font: string) {
if (/^[\w-]+$/.test(font)) return font
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
}
function stack(font: string | undefined, base: string, key?: string) {
const value = input(font, key).trim()
if (!value) return base
return `${family(value)}, ${base}`
}
export function monoInput(font: string | undefined) {
return input(font, monoKey)
}
export function sansInput(font: string | undefined) {
return input(font)
}
export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase, monoKey)
}
export function sansFontFamily(font: string | undefined) {
return stack(font, sansBase)
}
const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showTerminal: false,
showReasoningSummaries: false,
shellToolPartsExpanded: true,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
},
updates: {
@@ -56,7 +110,8 @@ const defaultSettings: Settings = {
},
appearance: {
fontSize: 14,
font: "ibm-plex-mono",
font: "",
uiFont: "",
},
keybinds: {},
permissions: {
@@ -77,29 +132,6 @@ const defaultSettings: Settings = {
},
}
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoFonts: Record<string, string> = {
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
}
export function monoFontFamily(font: string | undefined) {
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
}
function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
@@ -111,7 +143,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
})
return {
@@ -132,6 +166,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value)
},
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
@@ -167,7 +221,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
setFont(value: string) {
setStore("appearance", "font", value)
setStore("appearance", "font", value.trim() ? value : "")
},
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
setUIFont(value: string) {
setStore("appearance", "uiFont", value.trim() ? value : "")
},
},
keybinds: {

View File

@@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 200
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>>()
@@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? messagePageSize
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
@@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]

View File

@@ -1,45 +1,18 @@
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const template = "Terminal {{number}}"
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
const numbered = [
template,
"محطة طرفية {{number}}",
"Терминал {{number}}",
"ターミナル {{number}}",
"터미널 {{number}}",
"เทอร์มินัล {{number}}",
"终端 {{number}}",
"終端機 {{number}}",
]
export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number))
return template.replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {

View File

@@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
return projectStore.provider
if (projectStore.provider_ready) return projectStore.provider
}
return globalSync.data.provider
}

View File

@@ -564,8 +564,10 @@ export const dict = {
"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.font.title": "خط الكود",
"settings.general.row.font.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": "قائمة انتظار",
@@ -592,19 +594,6 @@ export const dict = {
"settings.updates.action.checking": "جارٍ التحقق...",
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "بلا",
"sound.option.alert01": "تنبيه 01",
"sound.option.alert02": "تنبيه 02",
@@ -722,8 +711,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
"settings.permissions.tool.todoread.title": "قراءة المهام",
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
"settings.permissions.tool.todowrite.title": "كتابة المهام",
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
"settings.permissions.tool.webfetch.title": "جلب الويب",

View File

@@ -571,8 +571,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte",
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
"settings.general.row.font.title": "Fonte de código",
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
"settings.general.row.uiFont.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
"settings.general.row.followup.description":
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
@@ -600,19 +602,6 @@ export const dict = {
"settings.updates.action.checking": "Verificando...",
"settings.updates.toast.latest.title": "Você está atualizado",
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Nenhum",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
@@ -732,8 +721,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
"settings.permissions.tool.todoread.title": "Ler Tarefas",
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
"settings.permissions.tool.webfetch.title": "Buscar Web",

View File

@@ -636,8 +636,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
"settings.general.row.font.title": "Font za kod",
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
"settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
"settings.general.row.followup.option.queue": "Red čekanja",
@@ -667,19 +669,6 @@ export const dict = {
"settings.updates.action.checking": "Provjera...",
"settings.updates.toast.latest.title": "Sve je ažurno",
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Nijedan",
"sound.option.alert01": "Upozorenje 01",
"sound.option.alert02": "Upozorenje 02",
@@ -806,8 +795,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
"settings.permissions.tool.webfetch.title": "Web preuzimanje",

View File

@@ -631,8 +631,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
"settings.general.row.font.title": "Skrifttype",
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
"settings.general.row.font.title": "Kode-skrifttype",
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
"settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd",
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
"settings.general.row.followup.option.queue": "Kø",
@@ -662,19 +664,6 @@ export const dict = {
"settings.updates.toast.latest.title": "Du er opdateret",
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Ingen",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
@@ -800,8 +789,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
"settings.permissions.tool.todoread.title": "Læs To-do",
"settings.permissions.tool.todoread.description": "Læs to-do listen",
"settings.permissions.tool.todowrite.title": "Skriv To-do",
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
"settings.permissions.tool.webfetch.title": "Webhentning",

View File

@@ -581,8 +581,10 @@ export const dict = {
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
"settings.general.row.theme.title": "Thema",
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
"settings.general.row.font.title": "Code-Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
"settings.general.row.uiFont.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
"settings.general.row.followup.description":
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
@@ -611,19 +613,6 @@ export const dict = {
"settings.updates.action.checking": "Wird geprüft...",
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Keine",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
@@ -743,8 +732,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
"settings.permissions.tool.todoread.title": "Todo lesen",
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
"settings.permissions.tool.todowrite.title": "Todo schreiben",
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
"settings.permissions.tool.webfetch.title": "Web-Abruf",

View File

@@ -715,6 +715,7 @@ export const dict = {
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
@@ -729,12 +730,24 @@ export const dict = {
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.font.title": "Code Font",
"settings.general.row.font.description": "Customise the font used in code blocks and terminals",
"settings.general.row.uiFont.title": "UI Font",
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.followup.title": "Follow-up behavior",
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@@ -760,19 +773,6 @@ export const dict = {
"settings.updates.action.checking": "Checking...",
"settings.updates.toast.latest.title": "You're up to date",
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "None",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
@@ -900,8 +900,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Load a skill by name",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Run language server queries",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Read the todo list",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Update the todo list",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -639,8 +639,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
"settings.general.row.font.title": "Fuente",
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
"settings.general.row.font.title": "Fuente de código",
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
"settings.general.row.uiFont.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento",
"settings.general.row.followup.description":
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
@@ -672,19 +674,6 @@ export const dict = {
"settings.updates.action.checking": "Buscando...",
"settings.updates.toast.latest.title": "Estás al día",
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Ninguno",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
@@ -813,8 +802,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
"settings.permissions.tool.todoread.title": "Leer Todo",
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
"settings.permissions.tool.todowrite.title": "Escribir Todo",
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -578,8 +578,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
"settings.general.row.theme.title": "Thème",
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police",
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
"settings.general.row.font.title": "Police de code",
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
"settings.general.row.uiFont.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi",
"settings.general.row.followup.description":
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
@@ -608,19 +610,6 @@ export const dict = {
"settings.updates.action.checking": "Vérification...",
"settings.updates.toast.latest.title": "Vous êtes à jour",
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Aucun",
"sound.option.alert01": "Alerte 01",
"sound.option.alert02": "Alerte 02",
@@ -741,8 +730,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
"settings.permissions.tool.todoread.title": "Lire Todo",
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
"settings.permissions.tool.todowrite.title": "Écrire Todo",
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
"settings.permissions.tool.webfetch.title": "Récupération Web",

View File

@@ -568,8 +568,10 @@ export const dict = {
"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.font.title": "コードフォント",
"settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
"settings.general.row.followup.description":
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
@@ -597,19 +599,6 @@ export const dict = {
"settings.updates.action.checking": "確認中...",
"settings.updates.toast.latest.title": "最新です",
"settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "なし",
"sound.option.alert01": "アラート 01",
"sound.option.alert02": "アラート 02",
@@ -727,8 +716,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
"settings.permissions.tool.todoread.title": "Todo読み込み",
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
"settings.permissions.tool.todowrite.title": "Todo書き込み",
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
"settings.permissions.tool.webfetch.title": "Web取得",

View File

@@ -569,8 +569,10 @@ export const dict = {
"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.font.title": "코드 글꼴",
"settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴 사용자 지정",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
"settings.general.row.followup.option.queue": "대기열",
@@ -597,19 +599,6 @@ export const dict = {
"settings.updates.action.checking": "확인 중...",
"settings.updates.toast.latest.title": "최신 상태입니다",
"settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "없음",
"sound.option.alert01": "알림 01",
"sound.option.alert02": "알림 02",
@@ -726,8 +715,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
"settings.permissions.tool.todoread.title": "할 일 읽기",
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
"settings.permissions.tool.webfetch.title": "웹 가져오기",

View File

@@ -639,8 +639,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
"settings.general.row.font.title": "Skrift",
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
"settings.general.row.font.title": "Kodefont",
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
"settings.general.row.uiFont.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd",
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
"settings.general.row.followup.option.queue": "Kø",
@@ -668,19 +670,6 @@ export const dict = {
"settings.updates.action.checking": "Sjekker...",
"settings.updates.toast.latest.title": "Du er oppdatert",
"settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Ingen",
"sound.option.alert01": "Varsel 01",
"sound.option.alert02": "Varsel 02",
@@ -807,8 +796,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
"settings.permissions.tool.todoread.title": "Les gjøremål",
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
"settings.permissions.tool.webfetch.title": "Webhenting",

View File

@@ -570,8 +570,10 @@ export const dict = {
"Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego",
"settings.general.row.theme.title": "Motyw",
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka",
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
"settings.general.row.font.title": "Czcionka kodu",
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
"settings.general.row.uiFont.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji",
"settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce",
"settings.general.row.followup.option.queue": "Kolejka",
@@ -598,19 +600,6 @@ export const dict = {
"settings.updates.action.checking": "Sprawdzanie...",
"settings.updates.toast.latest.title": "Masz najnowszą wersję",
"settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Brak",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
@@ -729,8 +718,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
"settings.permissions.tool.todoread.title": "Odczyt Todo",
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
"settings.permissions.tool.todowrite.title": "Zapis Todo",
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",

View File

@@ -636,8 +636,10 @@ export const dict = {
"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.font.title": "Шрифт кода",
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
"settings.general.row.followup.description":
"Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
@@ -668,19 +670,6 @@ export const dict = {
"settings.updates.action.checking": "Проверка...",
"settings.updates.toast.latest.title": "У вас последняя версия",
"settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Нет",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
@@ -808,8 +797,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Чтение списка задач",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -630,8 +630,10 @@ export const dict = {
"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.font.title": "ฟอนต์โค้ด",
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
"settings.general.row.uiFont.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
"settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว",
"settings.general.row.followup.option.queue": "คิว",
@@ -659,19 +661,6 @@ export const dict = {
"settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว",
"settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "ไม่มี",
"sound.option.alert01": "เสียงเตือน 01",
"sound.option.alert02": "เสียงเตือน 02",
@@ -796,8 +785,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",

View File

@@ -643,8 +643,10 @@ export const dict = {
"OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
"settings.general.row.font.title": "Yazı Tipi",
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
"settings.general.row.font.title": "Kod Yazı Tipi",
"settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",
"settings.general.row.followup.description":
"Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
@@ -677,20 +679,6 @@ export const dict = {
"settings.updates.toast.latest.title": "Güncelsiniz",
"settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "Yok",
"sound.option.alert01": "Uyarı 01",
"sound.option.alert02": "Uyarı 02",
@@ -816,8 +804,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
"settings.permissions.tool.todoread.title": "Görev Oku",
"settings.permissions.tool.todoread.description": "Görev listesini oku",
"settings.permissions.tool.todowrite.title": "Görev Yaz",
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
"settings.permissions.tool.webfetch.title": "Web Getir",

View File

@@ -630,8 +630,10 @@ export const dict = {
"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.font.title": "代码字体",
"settings.general.row.font.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": "排队",
@@ -657,20 +659,6 @@ export const dict = {
"settings.updates.toast.latest.title": "已是最新版本",
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "无",
"sound.option.alert01": "警报 01",
"sound.option.alert02": "警报 02",
@@ -795,8 +783,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名称加载技能",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
"settings.permissions.tool.todoread.title": "读取待办",
"settings.permissions.tool.todoread.description": "读取待办列表",
"settings.permissions.tool.todowrite.title": "更新待办",
"settings.permissions.tool.todowrite.description": "更新待办列表",
"settings.permissions.tool.webfetch.title": "网页获取",

View File

@@ -625,8 +625,10 @@ export const dict = {
"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.font.title": "程式碼字型",
"settings.general.row.font.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": "佇列",
@@ -654,19 +656,6 @@ export const dict = {
"settings.updates.toast.latest.title": "已是最新版本",
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
"font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code",
"font.option.hack": "Hack",
"font.option.inconsolata": "Inconsolata",
"font.option.intelOneMono": "Intel One Mono",
"font.option.iosevka": "Iosevka",
"font.option.jetbrainsMono": "JetBrains Mono",
"font.option.mesloLgs": "Meslo LGS",
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.none": "無",
"sound.option.alert01": "警報 01",
"sound.option.alert02": "警報 02",
@@ -790,8 +779,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名稱載入技能",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
"settings.permissions.tool.todoread.title": "讀取待辦",
"settings.permissions.tool.todoread.description": "讀取待辦清單",
"settings.permissions.tool.todowrite.title": "更新待辦",
"settings.permissions.tool.todowrite.description": "更新待辦清單",
"settings.permissions.tool.webfetch.title": "Web Fetch",

View File

@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,25 @@ import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
const navigate = useNavigate()
const params = useParams()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
createEffect(() => {
const id = params.id
if (!id) return
void sync.session.sync(id)
})
return (
<DataProvider
data={sync.data}
@@ -29,50 +43,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const location = useLocation()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const navigate = useNavigate()
let invalid = ""
const [resolved] = createResource(
() => {
if (params.dir) return [location.pathname, params.dir] as const
},
async ([pathname, b64Dir]) => {
const directory = decode64(b64Dir)
const resolved = createMemo(() => {
if (!params.dir) return ""
return decode64(params.dir) ?? ""
})
if (!directory) {
if (invalid === params.dir) return
invalid = b64Dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
return await globalSDK
.createClient({
directory,
throwOnError: true,
})
.path.get()
.then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (resolved()) {
invalid = ""
return
}
if (invalid === dir) return
invalid = dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
})
return (
<Show when={resolved()} keyed>

View File

@@ -113,6 +113,14 @@ export default function Home() {
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />

View File

@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
let dialogRun = 0
let dialogDead = false
const params = useParams()
const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
})
onCleanup(() => {
dialogDead = true
dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
@@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({
title: language.t("toast.theme.title"),
description: nextTheme?.name ?? nextThemeId,
description: theme.name(nextThemeId),
})
}
@@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions()))
void playSoundById(settings.sounds.permissions())
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
@@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) {
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
// warm up child store to prevent flicker
globalSync.child(target.worktree)
openProject(target.worktree)
}
@@ -1152,10 +1152,10 @@ export default function Layout(props: ParentProps) {
},
]
for (const [id, definition] of availableThemeEntries()) {
for (const [id] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
title: language.t("command.theme.set", { theme: definition.name ?? id }),
title: language.t("command.theme.set", { theme: theme.name(id) }),
category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(),
onHighlight: () => {
@@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) {
})
function connectProvider() {
dialog.show(() => <DialogSelectProvider />)
const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
})
}
function openServer() {
dialog.show(() => <DialogSelectServer />)
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
})
}
function openSettings() {
dialog.show(() => <DialogSettings />)
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
}
function projectRoot(directory: string) {
@@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
const showEditProjectDialog = (project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
async function chooseProject() {
function resolve(result: string | string[] | null) {
@@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
})
}
}

View File

@@ -26,8 +26,8 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { checksum } from "@opencode-ai/util/encode"
import { useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -317,7 +317,6 @@ export default function Page() {
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const sdk = useSDK()
const settings = useSettings()
const prompt = usePrompt()
@@ -712,7 +711,6 @@ export default function Page() {
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
untrack(() => {
void sync.session.sync(id)
})
@@ -1184,8 +1182,6 @@ export default function Page() {
on(
() => sdk.directory,
() => {
void file.tree.list("")
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -1558,26 +1554,6 @@ export default function Page() {
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID)
const dir = base64Encode(sdk.directory)
return sdk.client.session
.fork(input)
.then((result) => {
const next = result.data
if (!next) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
})
return
}
prompt.set(value, undefined, { dir, id: next.id })
navigate(`/${dir}/session/${next.id}`)
})
.catch(fail)
}
const revert = (input: { sessionID: string; messageID: string }) => {
if (reverting()) return
return revertMutation.mutateAsync(input)
@@ -1596,7 +1572,7 @@ export default function Page() {
.map((item) => ({ id: item.id, text: line(item.id) }))
})
const actions = { fork, revert }
const actions = { revert }
createEffect(() => {
const sessionID = params.id
@@ -1640,6 +1616,9 @@ export default function Page() {
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
@@ -1711,7 +1690,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={lastUserMessage()}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({

View File

@@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -261,9 +266,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<>
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}>
{(_, i) => (
<button
@@ -275,13 +292,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={sending()}
onClick={() => jump(i())}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
}
footer={
<>
@@ -301,56 +343,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{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>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<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>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={picked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
aria-checked={on()}
disabled={sending()}
onClick={() => selectOption(i())}
onClick={customOpen}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onClick={customOpen}
onMouseDown={(e) => {
if (sending()) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
@@ -369,80 +476,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (sending()) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
</Show>
<div
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
data-slot="session-turn-list"
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -942,7 +943,10 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
style={{
"content-visibility": active() ? undefined : "auto",
"contain-intrinsic-size": active() ? undefined : "auto 500px",
}}
>
<Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">

View File

@@ -13,12 +13,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
@@ -34,6 +35,8 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const platform = usePlatform()
const settings = useSettings()
const sync = useSync()
const file = useFile()
const language = useLanguage()
@@ -42,9 +45,10 @@ export function SessionSidePanel(props: {
const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
@@ -293,9 +297,11 @@ export function SessionSidePanel(props: {
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
@@ -352,100 +358,102 @@ export function SessionSidePanel(props: {
</div>
</div>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<Show when={shown()}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
</Show>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
</aside>
</Show>

View File

@@ -7,14 +7,12 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -43,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const platform = usePlatform()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
@@ -74,6 +74,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
@@ -257,7 +258,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
onSelect: () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
},
}),
fileCommand({
id: "tab.close",
@@ -307,12 +312,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
...(shown()
? [
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
]
: []),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
@@ -333,7 +342,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
@@ -341,7 +350,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
@@ -351,7 +360,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
onSelect: () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
},
}),
mcpCommand({
id: "mcp.toggle",
@@ -359,7 +372,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
onSelect: () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
},
}),
agentCommand({
id: "agent.cycle",
@@ -487,7 +504,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
onSelect: () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
},
}),
...share,
]

View File

@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
queue(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"

View File

@@ -3,6 +3,14 @@ type ModelKey = {
modelID: string
}
type ModelItem = ModelKey & {
name: string
}
type AgentItem = {
name: string
}
type State = {
agent?: string
model?: ModelKey | null
@@ -26,6 +34,9 @@ export type ModelProbeState = {
pick?: State
base?: State
current?: string
variants?: string[]
models?: ModelItem[]
agents?: AgentItem[]
}
export type ModelWindow = Window & {
@@ -33,6 +44,11 @@ export type ModelWindow = Window & {
model?: {
enabled?: boolean
current?: ModelProbeState
controls?: {
setAgent?: (name: string | undefined) => void
setModel?: (value: ModelKey | undefined) => void
setVariant?: (value: string | undefined) => void
}
}
}
}
@@ -45,6 +61,8 @@ const clone = (state?: State) => {
}
}
let active: symbol | undefined
export const modelEnabled = () => {
if (typeof window === "undefined") return false
return (window as ModelWindow).__opencode_e2e?.model?.enabled === true
@@ -56,9 +74,15 @@ const root = () => {
}
export const modelProbe = {
set(input: ModelProbeState) {
bind(id: symbol, input: NonNullable<NonNullable<ModelWindow["__opencode_e2e"]>["model"]>["controls"]) {
const state = root()
if (!state) return
active = id
state.controls = input
},
set(id: symbol, input: ModelProbeState) {
const state = root()
if (!state || active !== id) return
state.current = {
...input,
model: input.model ? { ...input.model } : undefined,
@@ -70,11 +94,16 @@ export const modelProbe = {
: undefined,
pick: clone(input.pick),
base: clone(input.base),
variants: input.variants?.slice(),
models: input.models?.map((item) => ({ ...item })),
agents: input.agents?.map((item) => ({ ...item })),
}
},
clear() {
clear(id: symbol) {
const state = root()
if (!state) return
if (!state || active !== id) return
active = undefined
state.current = undefined
state.controls = undefined
},
}

View File

@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
const defaultTimeoutMs = 3000
const defaultRetryCount = 2
const defaultRetryDelayMs = 100
const cacheMs = 750
const healthCache = new Map<
string,
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
return (http: ServerConnection.HttpBase) => {
const key = cacheKey(http)
const hit = healthCache.get(key)
const now = Date.now()
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
const promise = checkServerHealth(http, fetcher).finally(() => {
const next = healthCache.get(key)
if (!next || next.promise !== promise) return
next.done = true
next.at = Date.now()
})
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
return promise
}
}

View File

@@ -1,106 +1,89 @@
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
let files: Record<string, () => Promise<string>> | undefined
let loads: Record<SoundID, () => Promise<string>> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
string,
() => Promise<string>
>
return files
}
export const SOUND_OPTIONS = [
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
{ id: "alert-01", label: "sound.option.alert01" },
{ id: "alert-02", label: "sound.option.alert02" },
{ id: "alert-03", label: "sound.option.alert03" },
{ id: "alert-04", label: "sound.option.alert04" },
{ id: "alert-05", label: "sound.option.alert05" },
{ id: "alert-06", label: "sound.option.alert06" },
{ id: "alert-07", label: "sound.option.alert07" },
{ id: "alert-08", label: "sound.option.alert08" },
{ id: "alert-09", label: "sound.option.alert09" },
{ id: "alert-10", label: "sound.option.alert10" },
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
{ id: "staplebops-01", label: "sound.option.staplebops01" },
{ id: "staplebops-02", label: "sound.option.staplebops02" },
{ id: "staplebops-03", label: "sound.option.staplebops03" },
{ id: "staplebops-04", label: "sound.option.staplebops04" },
{ id: "staplebops-05", label: "sound.option.staplebops05" },
{ id: "staplebops-06", label: "sound.option.staplebops06" },
{ id: "staplebops-07", label: "sound.option.staplebops07" },
{ id: "nope-01", label: "sound.option.nope01" },
{ id: "nope-02", label: "sound.option.nope02" },
{ id: "nope-03", label: "sound.option.nope03" },
{ id: "nope-04", label: "sound.option.nope04" },
{ id: "nope-05", label: "sound.option.nope05" },
{ id: "nope-06", label: "sound.option.nope06" },
{ id: "nope-07", label: "sound.option.nope07" },
{ id: "nope-08", label: "sound.option.nope08" },
{ id: "nope-09", label: "sound.option.nope09" },
{ id: "nope-10", label: "sound.option.nope10" },
{ id: "nope-11", label: "sound.option.nope11" },
{ id: "nope-12", label: "sound.option.nope12" },
{ id: "yup-01", label: "sound.option.yup01" },
{ id: "yup-02", label: "sound.option.yup02" },
{ id: "yup-03", label: "sound.option.yup03" },
{ id: "yup-04", label: "sound.option.yup04" },
{ id: "yup-05", label: "sound.option.yup05" },
{ id: "yup-06", label: "sound.option.yup06" },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
function getLoads() {
if (loads) return loads
loads = Object.fromEntries(
Object.entries(getFiles()).flatMap(([path, load]) => {
const file = path.split("/").at(-1)
if (!file) return []
return [[file.replace(/\.aac$/, ""), load] as const]
}),
) as Record<SoundID, () => Promise<string>>
return loads
}
const cache = new Map<SoundID, Promise<string | undefined>>()
export function soundSrc(id: string | undefined) {
if (!id) return
if (!(id in soundById)) return
return soundById[id as SoundID]
const loads = getLoads()
if (!id || !(id in loads)) return Promise.resolve(undefined)
const key = id as SoundID
const hit = cache.get(key)
if (hit) return hit
const next = loads[key]().catch(() => undefined)
cache.set(key, next)
return next
}
export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
if (!src) return
const audio = new Audio(src)
audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}

View File

@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
/**
* @type {import("vite").PluginOption}
*/
@@ -21,6 +24,15 @@ export default [
}
},
},
{
name: "opencode-desktop:theme-preload",
transformIndexHtml(html) {
return html.replace(
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
)
},
},
tailwindcss(),
solidPlugin(),
]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.2",
"version": "1.3.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -132,7 +132,7 @@ export async function handler(
retry,
stickyProvider,
)
validateModelSettings(authInfo)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
@@ -768,9 +768,10 @@ export async function handler(
return "balance"
}
function validateModelSettings(authInfo: AuthInfo) {
if (!authInfo) return
if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
if (billingSource === "lite") return
if (billingSource === "anonymous") return
if (authInfo!.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
}
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -14,6 +14,7 @@ import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js"
import { centsToMicroCents } from "../src/util/price.js"
import { getWeekBounds } from "../src/util/date.js"
import { ModelTable } from "../src/schema/model.sql.js"
// get input from command line
const identifier = process.argv[2]
@@ -178,9 +179,8 @@ async function printWorkspace(workspaceID: string) {
balance: `$${(row.balance / 100000000).toFixed(2)}`,
reload: row.reload ? "yes" : "no",
customerID: row.customerID,
liteSubscriptionID: row.liteSubscriptionID,
blackSubscriptionID: row.blackSubscriptionID,
blackSubscription: row.blackSubscriptionID
GO: row.liteSubscriptionID,
Black: row.blackSubscriptionID
? [
`Black ${row.blackSubscription.enrichment!.plan}`,
row.blackSubscription.enrichment!.seats > 1
@@ -223,6 +223,50 @@ async function printWorkspace(workspaceID: string) {
),
)
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
/*
await printTable("Usage", (tx) =>
tx
@@ -248,6 +292,22 @@ async function printWorkspace(workspaceID: string) {
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
*/
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.2",
"version": "1.3.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -17,9 +17,9 @@
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
"@ai-sdk/openai-compatible": "1.0.1",
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@hono/zod-validator": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.2",
"version": "1.3.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -14,11 +14,6 @@ const getBase = (): Configuration => ({
},
files: ["out/**/*", "resources/**/*"],
extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
{
from: "native/",
to: "native/",

View File

@@ -1,5 +1,6 @@
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -7,6 +8,8 @@ const channel = (() => {
return "dev"
})()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
export default defineConfig({
main: {
define: {
@@ -17,6 +20,25 @@ export default defineConfig({
input: { index: "src/main/index.ts" },
},
},
plugins: [
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
enforce: "post",
async closeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (l.endsWith(".js")) continue
await fs.writeFile(`./out/main/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
},
preload: {
build: {

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.2",
"version": "1.3.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -30,6 +30,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@valibot/to-json-schema": "1.6.0",
"effect": "catalog:",
"electron-log": "^5",
"electron-store": "^10",
@@ -37,7 +38,9 @@
"electron-window-state": "^5.0.3",
"marked": "^15",
"solid-js": "catalog:",
"tree-kill": "^1.2.2"
"sury": "11.0.0-alpha.4",
"tree-kill": "^1.2.2",
"zod-openapi": "5.4.6"
},
"devDependencies": {
"@actions/artifact": "4.0.0",

View File

@@ -1,17 +1,5 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
const RUST_TARGET = Bun.env.RUST_TARGET
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
await $`cd ../opencode && bun script/build-node.ts`

View File

@@ -2,7 +2,7 @@
import { $ } from "bun"
import { Script } from "@opencode-ai/script"
import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
import { resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
@@ -12,13 +12,4 @@ pkg.version = Script.version
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
await $`rm -rf ${dir}`
await $`cd ../opencode && bun script/build-node.ts`

View File

@@ -1,280 +0,0 @@
import { execFileSync, spawn } from "node:child_process"
import { EventEmitter } from "node:events"
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import readline from "node:readline"
import { fileURLToPath } from "node:url"
import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
const CLI_BINARY_NAME = "opencode"
export type ServerConfig = {
hostname?: string
port?: number
}
export type Config = {
server?: ServerConfig
}
export type TerminatedPayload = { code: number | null; signal: number | null }
export type CommandEvent =
| { type: "stdout"; value: string }
| { type: "stderr"; value: string }
| { type: "error"; value: string }
| { type: "terminated"; value: TerminatedPayload }
| { type: "sqlite"; value: SqliteMigrationProgress }
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
const root = dirname(fileURLToPath(import.meta.url))
export function getSidecarPath() {
const suffix = process.platform === "win32" ? ".exe" : ""
const path = app.isPackaged
? join(process.resourcesPath, `opencode-cli${suffix}`)
: join(root, "../../resources", `opencode-cli${suffix}`)
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
return path
}
export async function getConfig(): Promise<Config | null> {
const { events } = spawnCommand("debug config", {})
let output = ""
await new Promise<void>((resolve) => {
events.on("stdout", (line: string) => {
output += line
})
events.on("stderr", (line: string) => {
output += line
})
events.on("terminated", () => resolve())
events.on("error", () => resolve())
})
try {
return JSON.parse(output) as Config
} catch {
return null
}
}
export async function installCli(): Promise<string> {
if (process.platform === "win32") {
throw new Error("CLI installation is only supported on macOS & Linux")
}
const sidecar = getSidecarPath()
const scriptPath = join(app.getAppPath(), "install")
const script = readFileSync(scriptPath, "utf8")
const tempScript = join(tmpdir(), "opencode-install.sh")
writeFileSync(tempScript, script, "utf8")
chmodSync(tempScript, 0o755)
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
return await new Promise<string>((resolve, reject) => {
cmd.on("exit", (code: number | null) => {
try {
unlinkSync(tempScript)
} catch {}
if (code === 0) {
const installPath = getCliInstallPath()
if (installPath) return resolve(installPath)
return reject(new Error("Could not determine install path"))
}
reject(new Error("Install script failed"))
})
})
}
export function syncCli() {
if (!app.isPackaged) return
const installPath = getCliInstallPath()
if (!installPath) return
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
const cli = parseVersion(version)
const appVersion = parseVersion(app.getVersion())
if (!cli || !appVersion) return
if (compareVersions(cli, appVersion) >= 0) return
void installCli().catch(() => undefined)
}
export function serve(hostname: string, port: number, password: string) {
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
const env = {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
}
return spawnCommand(args, env)
}
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Spawning command with args: ${args}`)
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const envs = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const { cmd, cmdArgs } = buildCommand(args, envs)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})
console.log(`[cli] Spawned process with PID: ${child.pid}`)
const events = new EventEmitter()
const exit = new Promise<TerminatedPayload>((resolve) => {
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
resolve({ code: code ?? null, signal: null })
})
child.on("error", (error: Error) => {
console.error(`[cli] Process error: ${error.message}`)
events.emit("error", error.message)
})
})
const stdout = child.stdout
const stderr = child.stderr
if (stdout) {
readline.createInterface({ input: stdout }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stdout", `${line}\n`)
})
}
if (stderr) {
readline.createInterface({ input: stderr }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stderr", `${line}\n`)
})
}
exit.then((payload) => {
events.emit("terminated", payload)
})
const kill = () => {
if (!child.pid) return
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
if (!stripped) return false
if (stripped === "done") {
events.emit("sqlite", { type: "Done" })
return true
}
const value = Number.parseInt(stripped, 10)
if (!Number.isNaN(value)) {
events.emit("sqlite", { type: "InProgress", value })
return true
}
return false
}
function buildCommand(args: string, env: Record<string, string>) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
const script = [
"set -e",
'BIN="$HOME/.opencode/bin/opencode"',
'if [ ! -x "$BIN" ]; then',
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
"fi",
`${envPrefix(env)} exec "$BIN" ${args}`,
].join("\n")
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
}
if (process.platform === "win32") {
const sidecar = getSidecarPath()
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
return { cmd: sidecar, cmdArgs: args.split(" ") }
}
const sidecar = getSidecarPath()
const shell = process.env.SHELL || "/bin/sh"
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
return entries.join(" ")
}
function shellEscape(input: string) {
if (!input) return "''"
return `'${input.replace(/'/g, `'"'"'`)}'`
}
function getCliInstallPath() {
const home = process.env.HOME
if (!home) return null
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
}
function isWslEnabled() {
return store.get(WSL_ENABLED_KEY) === true
}
function parseVersion(value: string) {
const parts = value
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10))
if (parts.some((part) => Number.isNaN(part))) return null
return parts
}
function compareVersions(a: number[], b: number[]) {
const len = Math.max(a.length, b.length)
for (let i = 0; i < len; i += 1) {
const left = a[i] ?? 0
const right = b[i] ?? 0
if (left > right) return 1
if (left < right) return -1
}
return 0
}

View File

@@ -5,3 +5,25 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@@ -8,6 +8,8 @@ import type { Event } from "electron"
import { app, BrowserWindow, dialog } from "electron"
import pkg from "electron-updater"
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
@@ -24,8 +26,6 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import type { CommandChild } from "./cli"
import { installCli, syncCli } from "./cli"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
@@ -33,12 +33,13 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null
let server: Server.Listener | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
@@ -93,11 +94,9 @@ function setupApp() {
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
setDockIcon()
setupAutoUpdater()
syncCli()
await initialize()
})
}
@@ -131,8 +130,8 @@ async function initialize() {
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
serverReady.resolve({
url,
username: "opencode",
@@ -142,7 +141,7 @@ async function initialize() {
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
events.on("sqlite", (progress: SqliteMigrationProgress) => {
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
@@ -195,9 +194,6 @@ function wireMenu() {
if (!mainWindow) return
createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
installCli: () => {
void installCli()
},
checkForUpdates: () => {
void checkForUpdates(true)
},
@@ -212,7 +208,6 @@ function wireMenu() {
registerIpcHandlers({
killSidecar: () => killSidecar(),
installCli: async () => installCli(),
awaitInitialization: async (sendStep) => {
sendStep(initStep)
const listener = (step: InitStep) => sendStep(step)
@@ -244,16 +239,9 @@ registerIpcHandlers({
})
function killSidecar() {
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
if (!server) return
server.stop()
server = null
}
function ensureLoopbackNoProxy() {

View File

@@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
installCli: () => Promise<string>
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -34,7 +33,6 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("install-cli", () => deps.installCli())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
@@ -88,7 +86,7 @@ export function registerIpcHandlers(deps: Deps) {
"open-directory-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? "Choose a folder",
defaultPath: opts?.defaultPath,
})

View File

@@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => void
installCli: () => void
checkForUpdates: () => void
reload: () => void
relaunch: () => void
@@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(),
},
{
label: "Install CLI...",
click: () => deps.installCli(),
},
{
label: "Reload Webview",
click: () => deps.reload(),

View File

@@ -1,4 +1,4 @@
import { serve, type CommandChild } from "./cli"
import { Server, Log } from "virtual:opencode-server"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -29,8 +29,14 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
export async function spawnLocalServer(hostname: string, port: number, password: string) {
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
@@ -42,19 +48,10 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
}
}
const terminated = async () => {
const payload = await exit
throw new Error(
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
payload.signal ?? "unknown"
})`,
)
}
await Promise.race([ready(), terminated()])
await ready()
})()
return { child, health: { wait }, events }
return { listener, health: { wait } }
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -82,5 +79,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
return false
}
}
export type { CommandChild }

View File

@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
const next = new Store({ name })
const next = new Store({ name, fileExtension: "" })
cache.set(name, next)
return next
}

View File

@@ -6,6 +6,9 @@ import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
@@ -246,6 +249,17 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount())
@@ -257,6 +271,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
@@ -309,15 +324,14 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
disableHealthCheck={(windowCount() ?? 0) > 1}
>
<Inner />
</AppInterface>

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.2",
"version": "1.3.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -6,6 +6,9 @@ import {
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
@@ -414,6 +417,17 @@ void listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
// Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@@ -423,6 +437,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [locale] = createResource(loadLocale)
// Build the sidecar server connection once credentials arrive
const servers = () => {
@@ -465,8 +480,8 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
{(_) => {
return (
<AppInterface

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.2"
version = "1.3.3"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.2",
"version": "1.3.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

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