Compare commits

...

309 Commits

Author SHA1 Message Date
Luke Parker
529a4d5e72 fix(build-node): preserve Bun linker for node-pty install (#20364) 2026-04-02 08:07:17 +10:00
Brendan Allan
751d42d6a6 cleanup 2026-04-01 14:52:29 +08:00
Brendan Allan
0b93d2d8fb Merge branch 'dev' into refactor/hono-server 2026-04-01 12:23:29 +08:00
Kit Langton
c8ecd64022 test(app): add mock llm e2e fixture (#20375) 2026-03-31 21:24:39 -04:00
opencode-agent[bot]
ca376a4cff chore: update nix node_modules hashes 2026-04-01 01:15:51 +00:00
Kit Langton
7532d99e5b test: finish HTTP mock processor coverage (#20372) 2026-04-01 00:45:42 +00:00
Kit Langton
181b5f6236 refactor(prompt): use Provider service in effect layers (#20167) 2026-04-01 00:44:15 +00:00
opencode
6314f09c14 release: v1.3.13 2026-04-01 00:44:06 +00:00
Sebastian
4b4b7832aa upgrade opentui to 0.1.95 (#20369) 2026-04-01 01:53:05 +02:00
opencode-agent[bot]
4280307013 chore: update nix node_modules hashes 2026-03-31 23:19:18 +00:00
opencode-agent[bot]
9b09a7e766 chore: generate 2026-03-31 23:15:56 +00:00
Kit Langton
3fc0367b93 refactor(session): effectify SessionRevert service (#20143) 2026-03-31 19:14:49 -04:00
Kit Langton
954a6ca88e refactor(session): effectify SessionSummary service (#20142) 2026-03-31 19:14:45 -04:00
Kit Langton
0c03a3ee10 test: migrate prompt tests to HTTP mock LLM server (#20304) 2026-03-31 19:14:32 -04:00
github-actions[bot]
53330a518f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20333#issuecomment-4166038038
2026-03-31 22:35:10 +00:00
opencode
892bdebaac release: v1.3.12 2026-03-31 22:35:01 +00:00
Sebastian
18121300f3 upgrade opentui to 0.1.94 (#20357) 2026-03-31 23:54:13 +02:00
github-actions[bot]
d6d4446f46 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20342#issuecomment-4165277636
2026-03-31 20:24:07 +00:00
Major Hayden
26cc924ea2 feat: enable prompt caching and cache token tracking for google-vertex-anthropic (#20266)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-03-31 15:16:14 -05:00
Aiden Cline
4dd866d5c4 fix: rm exclusion of ai-sdk/azure in transform.ts, when we migrated to v6 the ai sdk changed the key for ai-sdk/azure so the exclusion is no longer needed (#20326) 2026-03-31 14:57:15 -05:00
opencode
beab4cc2c2 release: v1.3.11 2026-03-31 19:55:41 +00:00
Dax
567a91191a refactor(session): simplify LLM stream by replacing queue with fromAsyncIterable (#20324) 2026-03-31 15:27:51 -04:00
Aiden Cline
434d82bbe2 test: update model test fixture (#20182) 2026-03-31 16:20:01 +00:00
Aiden Cline
2929774acb chore: rm harcoded model definition from codex plugin (#20294) 2026-03-31 11:13:11 -05:00
Adam
6e61a46a84 chore: skip 2 tests 2026-03-31 10:56:06 -05:00
Yuxin Dong
2daf4b805a feat: add a dedicated system prompt for Kimi models (#20259)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
2026-03-31 17:44:17 +02:00
opencode-agent[bot]
7342e650c0 chore: update nix node_modules hashes 2026-03-31 15:33:12 +00:00
Adam
8c2e2ecc95 chore: e2e model 2026-03-31 10:14:26 -05:00
Sebastian
25a2b739e6 warn only and ignore plugins without entrypoints, default config via exports (#20284) 2026-03-31 17:14:03 +02:00
Adam
85c16926c4 chore: use paid zen model in e2e 2026-03-31 10:06:44 -05:00
Sebastian
2e78fdec43 ensure pinned plugin versions and do not run package scripts on install (#20248) 2026-03-31 16:59:43 +02:00
Sebastian
1fcb920eb4 upgrade opentui to 0.1.93 (#19950) 2026-03-31 16:50:23 +02:00
opencode
b1e89c344b release: v1.3.10 2026-03-31 13:31:37 +00:00
Dax
befbedacdc fix(session): subagents not being clickable (#20263) 2026-03-31 08:58:46 -04:00
Frank
2cc738fb17 wip: zen 2026-03-31 00:07:56 -04:00
opencode-agent[bot]
71b20698bb chore: generate 2026-03-31 01:57:41 +00:00
Kit Langton
3df18dcde1 refactor(provider): effectify Provider service (#20160) 2026-03-30 21:56:43 -04:00
Kit Langton
a898c2ea3a refactor(storage): effectify Storage service (#20132) 2026-03-31 01:16:02 +00:00
Kit Langton
bf777298c8 fix(theme): darken muted text in catppuccin tui themes (#20161) 2026-03-30 21:06:05 -04:00
Luke Parker
93fad99f7f smarter changelog (#20138) 2026-03-31 00:05:46 +00:00
opencode
057848deb8 release: v1.3.9 2026-03-30 23:43:58 +00:00
Luke Parker
1de06452d3 fix(plugin): properly resolve entrypoints without leading dot (#20140) 2026-03-31 09:21:17 +10:00
Frank
58f60629a1 wip: zen 2026-03-30 19:04:42 -04:00
Frank
39a47c9b8c wip: zen 2026-03-30 18:50:09 -04:00
opencode-agent[bot]
ea88044f2e chore: generate 2026-03-30 21:49:45 +00:00
Kit Langton
e6f6f7aff1 refactor: replace Filesystem util with AppFileSystem service (#20127) 2026-03-30 21:48:28 +00:00
opencode
48e97b47af release: v1.3.8 2026-03-30 21:35:10 +00:00
opencode-agent[bot]
fe120e3cbf chore: update nix node_modules hashes 2026-03-30 20:46:10 +00:00
Frank
f2dd774660 zen: qwen3.6 plus 2026-03-30 16:43:55 -04:00
Frank
e7ff0f17c8 zen: qwen3.6 plus 2026-03-30 16:32:22 -04:00
Kit Langton
2ed756c72c fix(session): restore busy route handling and add regression coverage (#20125) 2026-03-30 20:30:34 +00:00
Frank
054f4be185 zen: add safety identifier 2026-03-30 16:28:40 -04:00
Jaaneek
e3e1e9af50 fix(Opencode): Bump ai-sdk/xai to 3.0.75 to fix tool calls (#20123)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-30 15:11:21 -05:00
opencode-agent[bot]
c8389cf96d chore: generate 2026-03-30 20:08:09 +00:00
Kit Langton
c5442d418d refactor(session): effectify SessionPrompt service (#19483) 2026-03-30 20:06:51 +00:00
Sebastian
fa95a61c4e Refactor into plugin loader and do not enforce (#20112) 2026-03-30 20:36:21 +02:00
Aiden Cline
9f3c2bd861 fix: agent value passed to chat.params and chat.headers hooks was not a string (#19996) 2026-03-30 16:32:27 +00:00
Adam
c2f78224ae chore(app): cleanup (#20062) 2026-03-30 08:50:42 -05:00
Sebastian
14f9e21d5c pluggable home footer (#20057) 2026-03-30 14:33:01 +02:00
Sebastian
8e4bab5181 update plugin themes when plugin was updated (#20052) 2026-03-30 13:51:07 +02:00
Jack
3c32013eb1 fix: preserve image attachments when selecting slash commands (#19771) 2026-03-30 17:11:34 +08:00
opencode
47d2ab120a release: v1.3.7 2026-03-30 06:06:12 +00:00
Luke Parker
186af2723d make variant modal less annoying (#19998) 2026-03-30 15:42:38 +10:00
Luke Parker
6926fe1c74 fix: stabilize release changelog generation (#19987) 2026-03-30 04:37:02 +00:00
Chris Yang
ee018d5c82 docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) 2026-03-29 23:01:57 -05:00
opencode-agent[bot]
0465579d6b chore: update nix node_modules hashes 2026-03-30 03:53:11 +00:00
Knut Zuidema
196a03caff fix: discourage _noop tool call during LiteLLM compaction (#18539) 2026-03-29 22:48:17 -05:00
Luke Parker
b234370080 feat(windows): add first-class pwsh/powershell support (#16069) 2026-03-30 13:10:01 +10:00
Sebastian
5d2dc8888c theme colors for dialog textarea placeholders (#19939) 2026-03-29 21:37:46 +02:00
Sebastian
0b1018f6dd plugins installs should preserve jsonc comments (#19938) 2026-03-29 21:15:03 +02:00
Aiden Cline
afb6abff73 fix: ensure OPENCODE_DISABLE_CLAUDE_CODE_PROMPT is respected for project lvl CLAUDE.md (#19924) 2026-03-29 18:02:29 +00:00
opencode
e7f94f9b9a release: v1.3.6 2026-03-29 18:02:20 +00:00
ualtinok
72c77d0e7b fix(session): fix token usage double-counting w/ anthropic & bedrock due to AI SDK v6 upgrade (#19758)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-03-29 12:40:10 -05:00
Ariane Emory
5c15755a10 docs: add question tool to available permissions list (#19922) 2026-03-29 17:35:52 +00:00
Frank
3a4bfeb5b5 wip: zen 2026-03-29 13:06:00 -04:00
Frank
1037c72d99 wip: zen 2026-03-29 12:47:53 -04:00
Kit Langton
ba00e9a993 Fix variant dialog filtering (#19917) 2026-03-29 16:43:13 +00:00
Frank
963dad75ef ci: fix 2026-03-29 12:41:52 -04:00
Frank
7e9b721e97 wip: zen 2026-03-29 12:04:20 -04:00
Luke Parker
a5b1dc081d test: add regression coverage for sync plugin hooks (#19589) 2026-03-29 03:43:00 +00:00
opencode
0bc2f99f2d release: v1.3.5 2026-03-29 03:42:52 +00:00
Aiden Cline
55895d0663 core: fix plugin hooks to properly handle async operations ensuring plugins can execute async code without errors (#19586) 2026-03-29 03:15:41 +00:00
Aiden Cline
72cb9dfa31 tweak: adjust gpt prompt to be more minimal, fix file reference annoyances (#19585) 2026-03-28 21:32:40 -05:00
opencode
f0a9075fdf release: v1.3.4 2026-03-29 01:00:44 +00:00
Luke Parker
fee1e25ab4 ci: cancel stale nix-hashes runs (#19571) 2026-03-29 10:39:02 +10:00
Frank
a94ac5aa2c zen: ZDR policy 2026-03-28 20:16:52 -04:00
Frank
62ac45a9c9 wip: zen 2026-03-28 20:16:51 -04:00
Frank
f7c2ef876f wip: zen 2026-03-28 20:16:51 -04:00
opencode-agent[bot]
6639f92739 chore: update nix node_modules hashes 2026-03-29 00:07:30 +00:00
Aiden Cline
36aeb32159 ignore: kill todo (#19566) 2026-03-29 00:06:32 +00:00
Caleb Norton
ff37d7c2df fix: nix embedded web-ui support (#19561) 2026-03-28 18:39:22 -05:00
Caleb Norton
4f96eb239f fix: respect semver build identifiers for nix (#11915) 2026-03-28 18:39:03 -05:00
Sebastian
38af99dcb4 prompt slot (#19563) 2026-03-29 00:27:27 +01:00
opencode-agent[bot]
772059acb5 chore: update nix node_modules hashes 2026-03-28 20:46:03 +00:00
Vladimir Glafirov
1f290fc1ba fix: update opencode-gitlab-auth to 2.0.1 (#19552) 2026-03-28 20:10:06 +00:00
Sebastian
77d4f99497 use theme color for prompt placeholder (#19535) 2026-03-28 17:20:37 +01:00
Dax
aa2d753e7e feat: dialog variant menu and subagent improvements (#19537) 2026-03-28 16:16:00 +00:00
Kit Langton
860531c275 refactor(session): effectify session processor (#19485) 2026-03-28 12:09:47 -04:00
Dax
2b86b36c8c feat: open dialog for model variant selection instead of cycling (#19534) 2026-03-28 15:37:20 +00:00
Sebastian
8ac2fbbd12 kv theme before default fallback (#19523) 2026-03-28 15:54:54 +01:00
opencode-agent[bot]
26382c6216 chore: update nix node_modules hashes 2026-03-28 14:52:13 +00:00
opencode-agent[bot]
0981b8eb71 chore: generate 2026-03-28 14:26:45 +00:00
Kit Langton
aa9ed001d3 refactor(file): use AppFileSystem instead of raw Filesystem (#19458) 2026-03-28 10:25:46 -04:00
Sebastian
6086072567 upgrade opentui to 0.1.91 (#19440) 2026-03-28 15:10:59 +01:00
Dax Raad
6c14ea1d22 tweak(session): add top spacing and remove obsolete docs prompt 2026-03-28 01:56:11 -04:00
Dax
c3a9ec4a99 fix: restore subagent footer and fix style guide violations (#19491) 2026-03-28 01:46:29 -04:00
Dax
41b0d03f6a feat: add model variant selection dialog (#19488) 2026-03-28 01:21:28 -04:00
Dax
81eb6e670b refactor(prompt): remove variant cycle display from footer (#19489) 2026-03-28 01:21:16 -04:00
Dax
8446719b13 refactor(session): move context into prompt footer (#19486) 2026-03-28 01:05:18 -04:00
Aiden Cline
15a8c22a26 tweak: adjust bash tool description to increase cache hit rates between projects (#19487) 2026-03-27 23:53:36 -05:00
opencode-agent[bot]
48326e8d9c chore: update nix node_modules hashes 2026-03-28 04:20:56 +00:00
Kit Langton
43bc5551e8 update effect to 4.0.0-beta.42 (#19484) 2026-03-27 23:25:05 -04:00
Adam
f736116967 fix(app): more startup efficiency (#19454) 2026-03-28 01:26:57 +00:00
Luke Parker
82fc493520 feat(ci): use Azure Artifact Signing for Windows releases (#15201) 2026-03-28 11:20:19 +10:00
Kit Langton
2145d97f18 refactor(session): effectify SessionCompaction service (#19459) 2026-03-28 01:09:14 +00:00
Sebastian
f3997d8082 Single target plugin entrypoints (#19467) 2026-03-27 23:44:46 +00:00
opencode-agent[bot]
02b19bc3d7 chore: generate 2026-03-27 21:38:08 +00:00
Kit Langton
5cd54ec345 refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457) 2026-03-27 17:37:07 -04:00
opencode-agent[bot]
c8909908f5 chore: update nix node_modules hashes 2026-03-27 21:11:06 +00:00
James Long
4b9660b211 refactor(core): move more responsibility to workspace routing (#19455) 2026-03-27 16:33:56 -04:00
Kit Langton
e5f0e813b6 refactor(session): effectify Session service (#19449) 2026-03-27 16:25:47 -04:00
Aiden Cline
c33d9996f0 feat: AI SDK v6 support (#18433) 2026-03-27 15:24:30 -05:00
Sebastian
7a7643c86a no theme override in dev (#19456) 2026-03-27 20:21:15 +00:00
Aiden Cline
6f5b70e681 tweak: add additional overflow error patterns (#19446) 2026-03-27 15:19:51 -05:00
Sebastian
ff13524a53 fix flaky plugin tests (no mock.module for bun) (#19445) 2026-03-27 20:55:03 +01:00
Kit Langton
e973bbf54a fix(app): default file tree to closed with minimum width (#19426) 2026-03-27 14:11:50 -04:00
Kit Langton
d36b38e4a6 fix(desktop-electron): match dev dock icon inset on macOS (#19429) 2026-03-27 17:32:05 +00:00
Burak Yigit Kaya
bdd7829c68 fix(app): resize layout viewport when mobile keyboard appears (#15841) 2026-03-27 11:39:13 -05:00
Shoubhit Dash
a93374c48f fix(ui): make streamed markdown feel more continuous (#19404) 2026-03-27 22:06:47 +05:30
Adam
af2ccc94eb chore(app): more spacing controls 2026-03-27 11:22:28 -05:00
James Long
a76be695c7 refactor(core): split out instance and route through workspaces (#19335) 2026-03-27 11:51:21 -04:00
Kit Langton
e528ed5d86 effectify Plugin service internals (#19365) 2026-03-27 15:20:11 +00:00
opencode-agent[bot]
bb8d2cdd10 chore: update nix node_modules hashes 2026-03-27 14:45:53 +00:00
Kit Langton
decb5e68ee effectify Skill service internals (#19364) 2026-03-27 10:15:51 -04:00
opencode-agent[bot]
21023337fa chore: generate 2026-03-27 14:01:28 +00:00
Sebastian
6274b0677c tui plugins (#19347) 2026-03-27 15:00:26 +01:00
opencode-agent[bot]
d8ad8338f5 chore: generate 2026-03-27 13:53:59 +00:00
Kit Langton
7b44918149 refactor(tool-registry): yield Config/Plugin services, use Effect.forEach (#19363) 2026-03-27 09:53:00 -04:00
Shoubhit Dash
d2bfa92e74 fix(app): persist queued followups across project switches (#19421) 2026-03-27 12:02:09 +00:00
opencode-agent[bot]
3fb60d05e5 chore: update nix node_modules hashes 2026-03-27 08:25:19 +00:00
Shoubhit Dash
d341499684 fix(ui): keep partial markdown readable while responses stream (#19403) 2026-03-27 07:46:47 +00: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 Raad
5079bf863e expect error 2026-03-26 20:41:12 -04:00
Dax Raad
57f7d39281 sync 2026-03-26 20:40:28 -04:00
Dax Raad
282ab0f67d Merge branch 'node-pty' into pr-18335 2026-03-26 19:04:00 -04:00
Dax Raad
ada7b11fd6 Merge branch 'dev' into pr-18335
Bring the PR up to date with the latest server, workspace, and UI changes from dev while keeping the PR's GitLab dependency and PTY merge resolutions.
2026-03-26 18:40:41 -04:00
Luke Parker
ef7d1f7efa fix: web ui bundle build on windows (#19337) 2026-03-26 22:14:20 +00: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
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
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
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
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
Dax
a6bff14a78 Merge branch 'dev' into refactor/hono-server 2026-03-20 11:05:04 -04:00
Dax
37ff5aaa5c Merge branch 'dev' into refactor/hono-server 2026-03-20 10:02:09 -04:00
Dax
b77c797c0f Merge branch 'dev' into refactor/hono-server 2026-03-20 00:00:24 -04:00
Dax Raad
3eeeec359a chore: extract misc fixes into #18328 2026-03-19 22:13:38 -04:00
Dax Raad
65e786258a chore: extract OAuth changes into #18327 2026-03-19 21:48:23 -04:00
Dax Raad
cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad
b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad
d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -04:00
Dax Raad
f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad
9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad
2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Dax Raad
fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad
08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad
0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad
850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -04:00
Dax Raad
48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -04:00
Dax Raad
b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -04:00
Dax Raad
bd7a4cec90 sync 2026-03-19 17:53:35 -04:00
Dax Raad
63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
Adam
0a53f8e084 chore: cleanup 2026-03-11 13:56:16 -05:00
Adam
6f5b2f786e wip: node-pty 2026-03-11 13:47:20 -05:00
Dax Raad
04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad
fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad
2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad
f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad
be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad
27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad
bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad
1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad
190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad
3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad
0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad
4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad
66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad
88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad
0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker
4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad
5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad
5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad
2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad
5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad
a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad
ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad
655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad
dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad
791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad
fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad
ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad
0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad
1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad
8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad
721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad
4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Dax
c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax
e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad
6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad
870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker
7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax
6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad
cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad
b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad
040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad
4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad
a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad
4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad
21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad
5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad
d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad
d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad
070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad
406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad
5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker
2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad
b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad
3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad
2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad
58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad
0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad
58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad
0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad
ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad
b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad
124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad
85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad
a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad
528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad
0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Dax
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad
e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad
656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
643 changed files with 108263 additions and 54692 deletions

3
.github/VOUCHED.td vendored
View File

@@ -21,7 +21,10 @@ jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-robinmordasiewicz
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot

View File

@@ -9,7 +9,8 @@ on:
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"

View File

@@ -17,6 +17,10 @@ on:
- "patches/**"
- ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.

View File

@@ -98,15 +98,129 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
path: |
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: false
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Verify Windows CLI signatures
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -152,6 +266,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -190,6 +312,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -246,11 +369,34 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -292,6 +438,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -326,6 +480,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -358,6 +513,22 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
@@ -373,6 +544,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -411,6 +583,16 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:

View File

@@ -1,54 +0,0 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error

View File

@@ -100,6 +100,9 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
OPENCODE_E2E_REQUIRE_PAID: "true"
timeout-minutes: 30
- name: Upload Playwright artifacts

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,34 +0,0 @@
---
description: ALWAYS use this when writing docs
color: "#38A3EE"
---
You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is separated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
The section titles are in the imperative mood
The section titles should not repeat the term used in the page title, for
example, if the page title is "Models", avoid using a section title like "Add
new models". This might be unavoidable in some cases, but try to avoid it.
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
For JS or TS code snippets remove trailing semicolons and any trailing commas
that might not be needed.
If you are making a commit prefix the commit message with `docs:`

View File

@@ -1,21 +1,46 @@
---
model: opencode/kimi-k2.5
model: opencode/gpt-5.4
---
create UPCOMING_CHANGELOG.md
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
it should have sections
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
```
# TUI
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
# Desktop
Rules:
# Core
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
# Misc
```
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
go through each PR merged since the last tag
<changelog_input>
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.
!`bun script/raw-changelog.ts $ARGUMENTS`
</changelog_input>

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,891 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
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 = (api: TuiPluginApi, input: Cfg) => ({
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_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
hint?: JSX.Element
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
},
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 = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, 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: TuiPlugin = async (api, options, meta) => {
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(api, value)) {
api.slots.register(item)
}
}
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin

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

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

18
.opencode/tui.json Normal file
View File

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

View File

@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"

1125
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-C7y5FMI1pGEgMw/vcPoBhK9tw5uGg1bk0gPXPUUVhgU=",
"aarch64-linux": "sha256-cUlQ9jp4WIaJkd4GRoHMWc+REG/OnnGCmsQUNmvg4is=",
"aarch64-darwin": "sha256-3GXmqG7yihJ91wS/jlW19qxGI62b1bFJnpGB4LcMlpY=",
"x86_64-darwin": "sha256-cUF0TfYg2nXnU80kWFpr9kNHlu9txiatIgrHTltgx4g="
}
}

View File

@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress

View File

@@ -3,6 +3,7 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
@@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -29,6 +31,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';

View File

@@ -12,6 +12,7 @@
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -25,7 +26,7 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@effect/platform-node": "4.0.0-beta.42",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -45,14 +46,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.42",
"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",
@@ -100,9 +102,11 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
@@ -112,8 +116,8 @@
},
"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",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.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

@@ -1,5 +1,8 @@
import { test as base, expect, type Page } from "@playwright/test"
import { ManagedRuntime } from "effect"
import type { E2EWindow } from "../src/testing/terminal"
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
import {
healthPhase,
cleanupSession,
@@ -13,9 +16,38 @@ import {
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
type LLMFixture = {
url: string
push: (...input: (Item | Reply)[]) => Promise<void>
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
tool: (name: string, input: unknown) => Promise<void>
toolHang: (name: string, input: unknown) => Promise<void>
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
fail: (message?: unknown) => Promise<void>
error: (status: number, body: unknown) => Promise<void>
hang: () => Promise<void>
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
calls: () => Promise<number>
wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number>
}
export const settingsKey = "settings.v3"
const seedModel = (() => {
const [providerID = "opencode", modelID = "big-pickle"] = (
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
).split("/")
return {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
type TestFixtures = {
llm: LLMFixture
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
@@ -26,7 +58,11 @@ type TestFixtures = {
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
options?: {
extra?: string[]
model?: { providerID: string; modelID: string }
setup?: (directory: string) => Promise<void>
},
) => Promise<T>
}
@@ -36,6 +72,31 @@ type WorkerFixtures = {
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
llm: async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
await use({
url: svc.url,
push: (...input) => rt.runPromise(svc.push(...input)),
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
fail: (message) => rt.runPromise(svc.fail(message)),
error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
})
} finally {
await rt.dispose()
}
},
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
@@ -89,7 +150,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const root = await createTestProject()
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
await options?.setup?.(root)
await seedStorage(page, { directory: root, extra: options?.extra, model: options?.model })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
@@ -123,9 +185,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
async function seedStorage(
page: Page,
input: {
directory: string
extra?: string[]
model?: { providerID: string; modelID: string }
},
) {
await seedProjects(page, input)
await page.addInitScript(() => {
await page.addInitScript((model: { providerID: string; modelID: string }) => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
@@ -143,12 +212,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
recent: [model],
user: [],
variant: {},
}),
)
})
}, input.model ?? seedModel)
}
export { expect }

View File

@@ -1,8 +1,44 @@
import fs from "node:fs/promises"
import path from "node:path"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
import { sessionIDFromUrl } from "../actions"
import { createSdk } from "../utils"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
async function config(dir: string, url: string) {
await fs.writeFile(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["e2e-llm"],
provider: {
"e2e-llm": {
name: "E2E LLM",
npm: "@ai-sdk/openai-compatible",
env: [],
models: {
"test-model": {
name: "Test Model",
tool_call: true,
limit: { context: 128000, output: 32000 },
},
},
options: {
apiKey: "test-key",
baseURL: url,
},
},
},
agent: {
build: {
model: "e2e-llm/test-model",
},
},
}),
)
}
test("can send a prompt and receive a reply", async ({ page, llm, withProject }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
@@ -11,42 +47,51 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
}
page.on("pageerror", onPageError)
await gotoSession()
const token = `E2E_OK_${Date.now()}`
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
try {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
await withProject(
async (project) => {
const sdk = createSdk(project.directory)
const token = `E2E_OK_${Date.now()}`
.toContain(token)
await llm.text(token)
await project.gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
project.trackSession(sessionID)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 30_000 },
)
.toContain(token)
},
{
model: { providerID: "e2e-llm", modelID: "test-model" },
setup: (dir) => config(dir, llm.url),
},
)
} finally {
page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {

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

@@ -234,6 +234,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
@@ -283,6 +284,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`

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", "System Mono")
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(initialFontFamily).toContain("IBM Plex 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("ui-monospace")
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),
mono: 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", "System Sans")
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("ui-sans-serif")
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: {
sans: 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: {
mono: "Reset Mono",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "System Mono")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(fontFamily).toContain("ui-monospace")
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: {
sans: "Reset Sans",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "System Sans")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
sans: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(fontFamily).toContain("ui-sans-serif")
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?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.sans === "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: {
mono,
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?.mono).toBe(mono)
expect(updatedSettings?.appearance?.sans).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,
mono,
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?.mono).toBe(mono)
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

View File

@@ -2,7 +2,7 @@
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />

View File

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

View File

@@ -71,7 +71,7 @@ const serverEnv = {
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>

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"
@@ -48,9 +47,14 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
@@ -77,11 +81,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 +129,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 +138,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>
@@ -184,7 +183,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
@@ -284,7 +283,11 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>

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))
@@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
}
@@ -1493,7 +1494,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

@@ -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

@@ -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,6 +67,10 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
@@ -104,9 +131,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 +151,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 +168,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 +179,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
playDemoSound(option.src)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
@@ -311,28 +321,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>

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,7 +5,7 @@ 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"

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

@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
heartbeat = undefined
}
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
const start = () => {
if (started) return run
started = true
run = (async () => {
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(flush)
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop()
abort.abort()
flush()
})
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))

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,10 +70,18 @@ function createGlobalSync() {
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => {
setProjectCache(
@@ -162,6 +160,7 @@ function createGlobalSync() {
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearProviderRev(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
@@ -258,6 +257,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 +283,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 +319,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,20 +338,36 @@ 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(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
}
void bootstrap()
})
@@ -392,13 +421,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,110 @@ 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(() => {
setTimeout(() => {
clearTimeout(timer)
finish()
}, 0)
})
})
}
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.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.path.get().then((x) => {
input.setGlobalStore("path", 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)
}),
),
]
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 +149,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 +195,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 = [
() => 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!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
]
const slow = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() =>
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.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" },
),
)
}
}),
)
}),
),
() => 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

@@ -13,7 +13,8 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344
const DEFAULT_SIDEBAR_WIDTH = 344
const DEFAULT_FILE_TREE_WIDTH = 200
const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -161,11 +162,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
return {
...fileTree,
opened: true,
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
tab: "changes",
}
})()
@@ -230,7 +231,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createStore({
sidebar: {
opened: false,
width: DEFAULT_PANEL_WIDTH,
width: DEFAULT_SIDEBAR_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
@@ -243,8 +244,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
panelOpened: true,
},
fileTree: {
opened: true,
width: DEFAULT_PANEL_WIDTH,
opened: false,
width: DEFAULT_FILE_TREE_WIDTH,
tab: "changes" as "changes" | "all",
},
session: {
@@ -543,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
return {
@@ -628,32 +643,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
return
}
setStore("fileTree", "tab", tab)
},
open() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", (x) => !x)

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

@@ -94,7 +94,11 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})

View File

@@ -32,7 +32,8 @@ export interface Settings {
}
appearance: {
fontSize: number
font: string
mono: string
sans: string
}
keybinds: Record<string, string>
permissions: {
@@ -42,13 +43,54 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
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 = monoFallback
const sansBase = sansFallback
function input(font: string | undefined) {
return font ?? ""
}
function family(font: string) {
if (/^[\w-]+$/.test(font)) return font
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
}
function stack(font: string | undefined, base: string) {
const value = font?.trim() ?? ""
if (!value) return base
return `${family(value)}, ${base}`
}
export function monoInput(font: string | undefined) {
return input(font)
}
export function sansInput(font: string | undefined) {
return input(font)
}
export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase)
}
export function sansFontFamily(font: string | undefined) {
return stack(font, sansBase)
}
const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
showReasoningSummaries: false,
shellToolPartsExpanded: true,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
},
updates: {
@@ -56,7 +98,8 @@ const defaultSettings: Settings = {
},
appearance: {
fontSize: 14,
font: "ibm-plex-mono",
mono: "",
sans: "",
},
keybinds: {},
permissions: {
@@ -77,29 +120,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 +131,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?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
return {
@@ -165,9 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
setFont(value: string) {
setStore("appearance", "font", value)
setStore("appearance", "mono", value.trim() ? value : "")
},
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
setUIFont(value: string) {
setStore("appearance", "sans", 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

@@ -729,8 +729,10 @@ 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",
@@ -760,19 +762,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 +889,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"
@@ -57,12 +57,15 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
const emptyFollowups: (FollowupDraft & { id: string })[] = []
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -317,7 +320,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()
@@ -513,15 +515,20 @@ export default function Page() {
deferRender: false,
})
const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>,
edit: {} as Record<
string,
{ id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
>,
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
paused: Record<string, boolean | undefined>
edit: Record<string, FollowupEdit | undefined>
}>({
items: {},
failed: {},
paused: {},
edit: {},
}),
)
createComputed((prev) => {
const key = sessionKey()
@@ -537,6 +544,8 @@ export default function Page() {
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@@ -711,8 +720,6 @@ export default function Page() {
if (!info) return true
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)
})
@@ -724,13 +731,47 @@ export default function Page() {
if (params.id !== id) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
})
}, 0)
})
}),
)
createEffect(
on(
() => {
const id = params.id
return [
sdk.directory,
id,
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk.directory !== dir || params.id !== id) return
untrack(() => {
void sync.session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -1184,8 +1225,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 +1597,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 +1615,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 +1659,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,
@@ -1653,6 +1675,15 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume,
})
createEffect(
on(
() => params.id,
(id) => {
if (!id) requestAnimationFrame(() => inputRef?.focus())
},
),
)
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
@@ -1662,6 +1693,8 @@ export default function Page() {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@@ -1711,7 +1744,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

@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
)
}
function Option(props: {
multi: boolean
picked: boolean
label: string
description?: string
disabled: boolean
onClick: VoidFunction
}) {
return (
<button
type="button"
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
<span data-slot="question-option-main">
<span data-slot="option-label">{props.label}</span>
<Show when={props.description}>
<span data-slot="option-description">{props.description}</span>
</Show>
</span>
</button>
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return language.t("session.question.progress", { current: n, total: total() })
})
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
@@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer)
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customUpdate(input())
}
const resizeInput = (el: HTMLTextAreaElement) => {
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}
const focusCustom = (el: HTMLTextAreaElement) => {
setTimeout(() => {
el.focus()
resizeInput(el)
}, 0)
}
const toggleCustomMark = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
customToggle()
}
const next = () => {
if (sending()) return
if (store.editing) commitCustom()
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</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>
)
}}
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
data-slot="question-option"
data-custom="true"
data-picked={on()}
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
disabled={sending()}
onClick={customOpen}
>
<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>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<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 data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
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>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
ref={focusCustom}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
resizeInput(e.currentTarget)
}}
/>
</span>

View File

@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const getCode = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const save = (next: ScrollPos) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
input.view().setScroll(input.tab(), out)
})
}
const onCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
save({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
}
const restore = () => {
const el = scroll
if (!el) return
const pos = input.view().scroll(input.tab())
if (!pos) return
sync()
if (code.length > 0) {
for (const item of code) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restore()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
return {
handleScroll,
queueRestore,
setViewport,
}
}
export function FileTabContent(props: { tab: string }) {
const file = useFile()
const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview =
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus())
})
const getCodeScroll = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
view().setScroll(props.tab, out)
})
}
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const cancelCommenting = () => {
const p = path()
if (p) file.setSelectedLines(p, null)
@@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
queueRestore()
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
onLoad: queueRestore,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>

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,7 +13,6 @@ 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"
@@ -293,9 +292,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>

View File

@@ -11,10 +11,6 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
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"
@@ -132,364 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
command.register("session", () => {
const share =
sync.data.config.share === "disabled"
? []
: [
sessionCommand({
id: "session.share",
title: info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: async () => {
if (!params.id) return
const write = async (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return Promise.resolve(true)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copyShare = async (url: string, existing: boolean) => {
if (!(await write(url))) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const share = async () => {
const sessionID = params.id
if (!sessionID) return
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const existing = info()?.share?.url
if (existing) {
await copyShare(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
const url = await sdk.client.session
.share({ sessionID })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copy(url, false)
},
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
},
}),
]
await copyShare(url, false)
}
const unshare = async () => {
const sessionID = params.id
if (!sessionID) return
await sdk.client.session
.unshare({ sessionID })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
}
const openFile = () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
}
const closeTab = () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
}
const addSelection = () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
}
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
}
const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
}
const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
}
const toggleAutoAccept = () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const prev = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(prev)
}
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID)
if (!next) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
return
}
await sdk.client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
}
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
}
const fork = () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
}
const shareCmds = () => {
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
},
}),
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
}),
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
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"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
onSelect: share,
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: unshare,
}),
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
}),
...share,
]
})
}
const sessionCmds = () => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
]
const fileCmds = () => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: closeTab,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: addSelection,
}),
]
const viewCmds = () => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
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"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
]
const terminalCmds = () => [
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: openTerminal,
}),
]
const messageCmds = () => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
]
const modelCmds = () => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: chooseModel,
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
]
const mcpCmds = () => [
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: chooseMcp,
}),
]
const agentCmds = () => [
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
]
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: toggleAutoAccept,
}),
]
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
...terminalCmds(),
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...agentCmds(),
...permissionsCmds(),
])
}

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.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "ألغِ في أي وقت.",
"go.faq.q5": "ماذا عن البيانات والخصوصية؟",
"go.faq.a5.body":
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
"go.faq.a5.contactAfter": "إذا كان لديك أي أسئلة.",
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
"go.faq.a5.beforeExceptions":
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancele a qualquer momento.",
"go.faq.q5": "E sobre dados e privacidade?",
"go.faq.a5.body":
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
"go.faq.a5.contactAfter": "se você tiver alguma dúvida.",
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
"go.faq.a5.beforeExceptions":
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
"go.faq.a5.exceptionsLink": "seguintes exceções",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Annuller til enhver tid.",
"go.faq.q5": "Hvad med data og privatliv?",
"go.faq.a5.body":
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
"go.faq.a5.contactAfter": "hvis du har spørgsmål.",
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
"go.faq.a5.exceptionsLink": "følgende undtagelser",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Jederzeit kündbar.",
"go.faq.q5": "Was ist mit Daten und Privatsphäre?",
"go.faq.a5.body":
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
"go.faq.a5.contactAfter": "wenn du Fragen hast.",
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
"go.faq.a5.beforeExceptions":
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",

View File

@@ -331,8 +331,8 @@ export const dict = {
"go.faq.a4.p3": "Cancel any time.",
"go.faq.q5": "What about data and privacy?",
"go.faq.a5.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
"go.faq.a5.contactAfter": "if you have any questions.",
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
"go.faq.a5.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"go.faq.a5.exceptionsLink": "following exceptions",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancela en cualquier momento.",
"go.faq.q5": "¿Qué pasa con los datos y la privacidad?",
"go.faq.a5.body":
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
"go.faq.a5.contactAfter": "si tienes alguna pregunta.",
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.",
"go.faq.a5.beforeExceptions":
"Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
"go.faq.a5.exceptionsLink": "siguientes excepciones",

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