Compare commits

..

97 Commits

Author SHA1 Message Date
LukeParkerDev d8525d71fb fix(app): preserve framed row paint 2026-07-09 14:41:28 +10:00
LukeParkerDev ae27fedfb0 fix(app): preserve tool card outlines 2026-07-09 13:37:11 +10:00
LukeParkerDev d0bce4f692 fix(ui): preserve shell outline position 2026-07-09 11:08:20 +10:00
Luke Parker 02724220ed Merge remote-tracking branch 'origin/dev' into shell-subpixel-resolve
# Conflicts:
#	packages/session-ui/src/components/message-part.css
2026-07-08 23:56:12 +00:00
opencode-agent[bot] 0abbcddac2 chore: add missing team members (#35980)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-09 09:53:05 +10:00
Luke Parker dd25d143c5 fix(app): reserve review pane minimum instead of capping chat width at 45% (#35078) 2026-07-09 09:47:46 +10:00
Luke Parker d8a57ee15c fix(app): unmount hidden session panes (#35628) 2026-07-09 09:44:02 +10:00
Luke Parker aa52d30d7f feat(app): persist review state per session (#35488) 2026-07-08 23:43:28 +00:00
Luke Parker 83d633fd13 fix(app): route legacy drafts to session page (#35808) 2026-07-09 07:38:56 +08:00
Luke Parker 824455e2f1 fix(app): load capped review patches (#35633) 2026-07-09 09:09:32 +10:00
Luke Parker 62de2b3695 feat(app): add inline file browser tabs (#35829) 2026-07-09 09:09:21 +10:00
opencode-agent[bot] 7550aa4b89 chore: update nix node_modules hashes 2026-07-08 22:51:45 +00:00
opencode-agent[bot] c33a2011c4 fix(app): prevent command palette first-open flash (#35858) 2026-07-09 06:51:04 +08:00
David Hill fff6764ed4 fix(console): update square avatar previews (#35975) 2026-07-08 22:48:11 +00:00
opencode-agent[bot] 9644a008a4 chore: generate 2026-07-08 22:31:09 +00:00
Aiden Cline ccb6b7c3ea fix: improve xai cache hit rate (#35970) 2026-07-08 17:29:36 -05:00
opencode-agent[bot] f95f877e5f fix(core): watch only git projects (#35957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-08 20:58:41 +00:00
Frank fe96d4aebd zen: grok 4.5
deploy / deploy (push) Has been cancelled
2026-07-08 15:52:56 -04:00
Frank 3a95d56144 Merge branch 'dev' of https://github.com/anomalyco/opencode into dev 2026-07-08 15:30:52 -04:00
Frank ecd8be7593 zen: grok 4.5 2026-07-08 15:30:49 -04:00
opencode-agent[bot] a72c3880bb chore: generate 2026-07-08 16:30:17 +00:00
Adam ccd8676bee feat(stats): add model comparison home 2026-07-08 11:28:28 -05:00
Adam 5a533e366f feat(stats): add model comparison pages 2026-07-08 11:28:27 -05:00
Luke Parker 77429f5982 feat(app): sync product translations (#35856)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-08 14:57:28 +10:00
Brendan Allan 6cfe4bc34f fix(app): route new session shortcut through tabs (#35849) 2026-07-08 11:44:56 +08:00
Brendan Allan 95013d2936 fix(app): keep session routes within layouts (#35842) 2026-07-08 03:24:21 +00:00
Adam 14a5529793 feat(data): redesign model peers
deploy / deploy (push) Has been cancelled
2026-07-07 11:15:43 -05:00
opencode 0b2e8707e3 sync release versions for v1.17.15 2026-07-07 15:31:36 +00:00
冯基魁 adf178a6b9 fix(llm): classify zai token limit overflow (#35671) 2026-07-07 09:46:27 -05:00
opencode-agent[bot] 78f85b1cd6 fix(core): ensure relevant files survive compaction (#35636)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-07 09:39:01 -05:00
opencode-agent[bot] 888dc67cc3 chore: generate 2026-07-07 14:25:21 +00:00
Dustin Deus fd9ee435ec fix(core): expand home-relative permission paths (#35737) 2026-07-07 16:23:50 +02:00
opencode-agent[bot] 1c25b2f298 chore: generate 2026-07-07 11:55:25 +00:00
Adam ea9fffce2e feat(data): redesign model efficiency 2026-07-07 06:53:36 -05:00
Brendan Allan 516f0266b8 fix(app): hide markdown task markers (#35670) 2026-07-07 15:22:49 +08:00
opencode-agent[bot] 39397deb8d chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-07 06:44:40 +00:00
Brendan Allan 65fd2e5c91 fix(app): prioritize shortcuts in terminal (#35668) 2026-07-07 06:43:30 +00:00
opencode-agent[bot] c5fe32fbb1 fix(app): show unread for pending questions (#34684)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 13:27:19 +08:00
opencode-agent[bot] c48b77d6f9 chore: generate 2026-07-07 05:12:15 +00:00
Brendan Allan 64fd7a2027 fix(app): persist session tab info (#34934) 2026-07-07 05:11:08 +00:00
opencode-agent[bot] ae259d87f0 fix(app): apply settings themes on select (#35011)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-07 13:03:25 +08:00
Aarav Sareen 50374abab8 feat(app): change traffic light position, fix sequoia bug (#35081) 2026-07-07 12:49:28 +08:00
opencode-agent[bot] 0497e8badf chore: generate 2026-07-07 04:46:52 +00:00
Aarav Sareen 2a08f8f5f4 feat(app): reintroduce model context tooltip in model selector (#35087) 2026-07-07 12:45:42 +08:00
opencode-agent[bot] 9353559088 chore: generate 2026-07-07 02:37:53 +00:00
Aarav Sareen 6aff2ee169 feat(app): visual improvements (#35134) 2026-07-07 10:36:27 +08:00
James Long 254a481e5d fix(config): handle unavailable config directories (#35632)
Co-authored-by: James Long <jlongster@users.noreply.github.com>
2026-07-06 22:11:13 -04:00
Aarav Sareen 26885e7118 feat(app): align cmd k menu with v2 styles (#35152)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 08:52:57 +08:00
opencode-agent[bot] dba08013d1 chore: generate 2026-07-06 20:10:55 +00:00
Adam fc09646973 fix(data): refine model trend charts 2026-07-06 15:03:07 -05:00
Adam c3b9c9d888 feat(data): improved model usage section 2026-07-06 15:03:07 -05:00
opencode 203885d241 sync release versions for v1.17.14 2026-07-06 18:50:51 +00:00
opencode-agent[bot] 728e86b287 chore: generate 2026-07-06 18:20:49 +00:00
Aiden Cline d5aa79c73a feat(codemode): sync v2 implementation (#35574) 2026-07-06 13:19:21 -05:00
opencode-agent[bot] eb6ff0c1e0 chore: update nix node_modules hashes 2026-07-06 13:35:10 +00:00
opencode-agent[bot] 0df44c4e91 chore: generate 2026-07-06 11:02:19 +00:00
Simon Klee 561070fbc2 deps: upgrade OpenTUI to v0.4.3 (#35226) 2026-07-06 13:01:08 +02:00
Brendan Allan b0e41ff2c4 fix(app): use selected home project for new sessions (#35530) 2026-07-06 16:37:50 +08:00
opencode-agent[bot] 977a40af68 chore: generate 2026-07-06 08:21:25 +00:00
Brendan Allan dffecb6478 fix(desktop): gate first launch onboarding (#34930) 2026-07-06 16:20:15 +08:00
Brendan Allan 7f57d2a9ac feat(app): show draft server status in titlebar (#35521) 2026-07-06 16:12:53 +08:00
opencode-agent[bot] 377d5d2287 fix(app): avoid shortcut settings flash (#35349)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-06 16:12:30 +08:00
Brendan Allan 38bb38ecb2 refactor(app): unify provider connect dialog (#35518) 2026-07-06 07:50:08 +00:00
Luke Parker 3a149ba71c fix(app): optimize large review panes (#35375)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-06 16:26:03 +10:00
opencode-agent[bot] 14df88eab5 fix(app): preserve provider dialog backdrop (#35370)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-06 06:08:59 +00:00
opencode-agent[bot] e12cb7fb6b chore: generate 2026-07-06 05:53:41 +00:00
Aiden Cline d4f7039932 fix(codemode): unify catalog signatures (#35452) 2026-07-06 00:52:37 -05:00
opencode-agent[bot] e0ec9be238 chore: update nix node_modules hashes 2026-07-06 03:49:25 +00:00
Aiden Cline 2b34df94fa fix(mcp): preserve metadata across tool pages (#35439) 2026-07-05 22:28:28 -05:00
opencode-agent[bot] 68f225a11d fix(provider): preserve OpenRouter small model effort (#35478)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-05 18:50:25 -05:00
David Hill e9f5d34096 fix(tui): shorten home tips (#31966) 2026-07-05 14:44:10 -05:00
Aiden Cline d3459eb740 test(mcp): replace module mocks with real servers (#35450) 2026-07-05 12:49:31 -05:00
Aiden Cline be73f465df refactor(codemode): namespace public types (#35425) 2026-07-05 10:34:58 -05:00
Aiden Cline f14eafe9db refactor(codemode): remove generic agent tool (#35417) 2026-07-05 09:30:46 -05:00
Jack b7e4f1ef74 fix: update Feishu community link (#35392)
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
deploy / deploy (push) Has been cancelled
2026-07-05 14:00:14 +08:00
Luke Parker efd5f0a80f fix(app): preserve timeline selection autoscroll (#35383) 2026-07-05 02:47:37 +00:00
opencode-agent[bot] 7135bc4fb5 chore: generate 2026-07-05 02:02:39 +00:00
Aarav Sareen 7cf8d1ca6d feat(app): terminal improvements (#34747)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-05 02:01:30 +00:00
opencode-agent[bot] 78bca5c707 chore: generate 2026-07-05 01:39:09 +00:00
usrnk1 82db86e2a1 feat(desktop): reopen closed tabs and background tab open (#35010)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-05 01:37:59 +00:00
opencode-agent[bot] 476e3fc382 chore: generate 2026-07-05 00:51:28 +00:00
Luke Parker a12d50e15a fix(app): hydrate timeline message parents (#35269) 2026-07-05 10:49:46 +10:00
opencode-agent[bot] 1b9b260458 chore: generate 2026-07-04 21:18:39 +00:00
Aiden Cline a8983bd2c7 feat(codemode): add OpenAPI tool adapter (#35192) 2026-07-04 16:17:30 -05:00
opencode-agent[bot] 709af58612 fix(core): stop after declined permissions (#35356)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-04 16:17:11 -05:00
opencode-agent[bot] bcbbf32569 fix(app): use v2 tooltip for prompt context (#35351)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
2026-07-04 14:54:46 -04:00
Simon Klee 7a8e7c88f4 tui: preserve spinner registration (#35292) 2026-07-04 09:44:23 +02:00
Luke Parker a22676720d fix(app): match new session panel corners (#35257)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-04 04:08:15 +00:00
Luke Parker f63a451b6f fix(desktop): stabilize esm shim injection (#35270) 2026-07-04 13:31:13 +10:00
opencode-agent[bot] 8b6a2b173b fix(app): delay initial tab preview (#35266)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-03 22:42:17 -04:00
Luke Parker 933dbfdb36 fix(app): restore unfocused composer typing (#35249) 2026-07-03 23:59:23 +00:00
opencode-agent[bot] b44bc0ad07 cleanup: tweak compaction prompt (#35220)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 15:26:03 -05:00
opencode-agent[bot] 436cd39d14 chore: generate 2026-07-03 18:42:23 +00:00
Aiden Cline ed6dc879be feat(opencode): gate execute tool behind code mode flag (#35185) 2026-07-03 13:41:15 -05:00
James Long 96d53c6716 refactor(core): move path resolve into fs service (#35202) 2026-07-03 13:25:25 -04:00
Aiden Cline 911fb7063a fix(tui): align execute child calls with task indentation (#35190) 2026-07-03 12:06:58 -05:00
Test ae0375f82f fix(ui): stabilize shell output outline 2026-06-25 09:17:33 +00:00
338 changed files with 25029 additions and 14861 deletions
+4
View File
@@ -1,4 +1,5 @@
adamdotdevin
arvsrn
Brendonovich
fwang
Hona
@@ -7,11 +8,14 @@ jayair
jlongster
kitlangton
kommander
ludvigrask
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
thdxr
simonklee
Slickstef11
usrnk1
vimtor
starptech
+1 -1
View File
@@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
+1 -1
View File
@@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
+61 -53
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -93,7 +93,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.13",
"version": "1.17.15",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -141,7 +141,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "0.0.1",
"version": "1.17.15",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -155,7 +155,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -191,7 +191,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -218,7 +218,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -240,7 +240,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -264,7 +264,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -284,7 +284,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.13",
"version": "1.17.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -308,7 +308,7 @@
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -378,7 +378,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -432,7 +432,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -446,7 +446,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"effect": "catalog:",
},
@@ -458,7 +458,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -490,7 +490,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -506,7 +506,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -537,7 +537,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -556,7 +556,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.13",
"version": "1.17.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -582,7 +582,7 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
@@ -687,7 +687,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -704,9 +704,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.3.4",
"@opentui/keymap": ">=0.3.4",
"@opentui/solid": ">=0.3.4",
"@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.3",
},
"optionalPeers": [
"@opentui/core",
@@ -763,7 +763,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -778,7 +778,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -793,7 +793,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -837,7 +837,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -850,7 +850,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -883,7 +883,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -902,7 +902,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -943,7 +943,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -970,7 +970,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1021,7 +1021,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1064,6 +1064,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
@@ -1073,7 +1074,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
@@ -1096,9 +1096,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1211,7 +1211,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -2031,27 +2031,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3243,7 +3243,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -5591,8 +5591,6 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
@@ -5695,7 +5693,11 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -6145,6 +6147,8 @@
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -6579,6 +6583,8 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6941,6 +6947,8 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
"x86_64-linux": "sha256-K0V7FyvgujbeAf0Ip4SCJxcy0GpSt/H+iEwTVhvwBRQ=",
"aarch64-linux": "sha256-lz2GhziKY9zL4vXb2/CmlXKrmVYPF5hPFD1ZpfDOqgM=",
"aarch64-darwin": "sha256-vfxsqozBq40tc1v6w8xvl1HIAif8gRn6oIYrLt6fZ1c=",
"x86_64-darwin": "sha256-X7GAwO+8A6vwqObIHFtloDDJZO8ZUTqmplFDg1zfots="
}
}
+5 -4
View File
@@ -19,6 +19,7 @@
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
@@ -39,9 +40,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
@@ -146,7 +147,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
@@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
})
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.getByText("History 79.", { exact: false })
await expect(text).toBeVisible()
await scroller.evaluate((element) => {
element.dataset.selectionLength = "0"
document.addEventListener("selectionchange", () => {
element.dataset.selectionLength = String(
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
)
})
})
const textBox = await text.boundingBox()
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(500)
await page.mouse.up()
})
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_keyboard_01_shell"
const followingID = "prt_keyboard_02_following"
@@ -0,0 +1,312 @@
import type { Page } from "@playwright/test"
import { benchmark, expect } from "../benchmark"
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
const changedLinesPerFile = 100
const linesPerSide = changedLinesPerFile / 2
const fileCounts = [1, 10, 100, 1_000, 10_000]
const filesPerDirectory = 100
const readyFrames = 3
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
type ReviewPaneScalingSample = {
observedAtMs: number
logicalRows: number
treeRows: number
fileRows: number
diffLines: number
header: string
ready: boolean
}
type ReviewPaneScalingProbe = {
startedAt?: number
firstTreeRowMs?: number
logicalTreeReadyMs?: number
firstDiffRenderMs?: number
stableReadyMs?: number
samples: ReviewPaneScalingSample[]
frameTimesMs: number[]
longTasks: { startTime: number; duration: number }[]
stop: () => void
}
benchmark.describe("performance: review pane scaling", () => {
for (const fileCount of fileCounts) {
const changedLines = fileCount * changedLinesPerFile
benchmark(
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
async ({ page, report }) => {
benchmark.setTimeout(1_200_000)
await page.emulateMedia({ reducedMotion: "reduce" })
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
await setupTimelineBenchmark(page, {
historyTurns: 0,
eventBatch: 1,
newLayoutDesigns: true,
})
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: responseBody,
}),
)
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
const metrics = await measureReviewPaneLoad(page, {
expectedFile: reviewFile(0),
expectedRows,
})
const search = await measureBroadReviewSearch(page, fileCount)
expect(metrics.logicalRows).toBe(expectedRows)
expect(metrics.fileRows).toBeGreaterThan(0)
expect(metrics.treeRows).toBeGreaterThan(0)
expect(metrics.diffLines).toBeGreaterThan(0)
expect(search.logicalRows).toBe(fileCount)
expect(search.renderedRows).toBeGreaterThan(0)
report(
{ ...metrics, search },
{
fileCount,
changedLinesPerFile,
changedLines,
additions: changedLines / 2,
deletions: changedLines / 2,
patchLines: changedLines,
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
expectedRows,
},
)
},
)
}
})
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.evaluate((element) => {
element.addEventListener(
"input",
() => {
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
},
{ once: true, capture: true },
)
})
await filter.fill("file-")
return page.evaluate((expectedRows) => {
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
let previous = -1
let streak = 0
const sample = () => {
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
previous = rows.length
if (streak >= 3) {
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
}, expectedRows)
}
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
const changes = Array.from({ length: linesPerSide }, (_, index) => {
const line = String(index).padStart(3, "0")
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
}).join("\n")
let patchBytes = 0
let capped = false
return Array.from({ length: fileCount }, (_, index) => {
const file = reviewFile(index)
const fullPatch = [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
changes,
].join("\n")
if (index === 0 && fullPatch.length > patchByteLimit)
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
if (patch === fullPatch) patchBytes += fullPatch.length
else capped = true
return {
file,
patch,
additions: linesPerSide,
deletions: linesPerSide,
status: "modified" as const,
}
})
}
function emptyReviewPatch(file: string) {
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
}
function reviewFile(index: number) {
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
}
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
const toggle = page.getByRole("button", { name: "Toggle review" })
await expect(toggle).toBeVisible()
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
await installReviewPaneScalingProbe(page, input)
await toggle.click()
await page.waitForFunction(
() =>
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
?.stableReadyMs !== undefined,
undefined,
{ timeout: completionTimeoutMs },
)
return page.evaluate(() => {
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
probe.stop()
const startedAt = probe.startedAt!
const final = probe.samples.at(-1)!
const resources = performance
.getEntriesByType("resource")
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
const resource = resources.at(-1)
const longTasks = probe.longTasks.filter(
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
)
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
return {
firstTreeRowMs: probe.firstTreeRowMs ?? null,
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
stableReadyMs: probe.stableReadyMs ?? null,
responseStartMs: resource ? resource.responseStart - startedAt : null,
responseEndMs: resource ? resource.responseEnd - startedAt : null,
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
treeRows: final.treeRows,
logicalRows: final.logicalRows,
fileRows: final.fileRows,
diffLines: final.diffLines,
samples: probe.samples.length,
maxFrameGapMs: Math.max(0, ...frameGaps),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
}
})
}
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
await page.evaluate(
({ expectedFile, expectedRows, stableFrames }) => {
let running = true
let readyStreak = 0
const basename = expectedFile.split("/").at(-1)!
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
? new PerformanceObserver((list) => {
probe.longTasks.push(
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
)
})
: undefined
const probe: ReviewPaneScalingProbe = {
samples: [],
frameTimesMs: [],
longTasks: [],
stop: () => {
running = false
longTaskObserver?.disconnect()
},
}
const sample = (time: number) => {
if (!running || probe.startedAt === undefined) return
const panel = document.querySelector<HTMLElement>("#review-panel")
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
const header =
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
const viewers = panel
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
: []
const diffLines = viewers.reduce(
(sum, viewer) =>
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
0,
)
const observedAtMs = time - probe.startedAt
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows &&
fileRows.length > 0 &&
header.includes(basename) &&
viewers.length === 1 &&
diffLines > 0
const previous = probe.samples.at(-1)
const stable =
ready &&
previous?.ready === true &&
previous.logicalRows === logicalRows &&
previous.treeRows === rows.length &&
previous.fileRows === fileRows.length &&
previous.diffLines === diffLines &&
previous.header === header
probe.frameTimesMs.push(observedAtMs)
probe.samples.push({
observedAtMs,
logicalRows,
treeRows: rows.length,
fileRows: fileRows.length,
diffLines,
header,
ready,
})
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
probe.logicalTreeReadyMs = observedAtMs
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
}
longTaskObserver?.observe({ type: "longtask", buffered: true })
document.addEventListener(
"click",
(event) => {
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
probe.startedAt = performance.now()
performance.mark("opencode.review-pane-scaling.click")
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
},
{ ...input, stableFrames: readyFrames },
)
}
@@ -0,0 +1,151 @@
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type ParentHydrationBenchmarkMode = "natural" | "candidate"
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
const userID = "msg_parent_hydration_user"
const user = {
...fixture.messages[fixture.targetID][0]!,
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
...part,
id: `prt_parent_hydration_user_${index}`,
messageID: userID,
})),
}
const assistantSeed = fixture.messages[fixture.targetID][3]!
const assistants = Array.from({ length: 14 }, (_, index) => {
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
return {
...assistantSeed,
info: {
...assistantSeed.info,
id: messageID,
parentID: userID,
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
},
parts: assistantSeed.parts.map((part, partIndex) => ({
...part,
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
messageID,
})),
}
})
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
const results = [] as Awaited<ReturnType<typeof trial>>[]
for (let run = 0; run < 5; run++) {
results.push(
await withBenchmarkPage(
browser,
`session-parent-hydration-${mode}-${run}`,
(page) => trial(page, mode),
testInfo,
),
)
}
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
report(
{
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
summary: {
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
blankSamples: results.map((result) => result.metrics.blankSamples),
requestCounts: {
list: results.map((result) => result.requestCounts.list),
parent: results.map((result) => result.requestCounts.parent),
},
historyGateCount: results.map((result) => result.historyGateCount),
},
},
{ mode },
)
})
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
const requests: { type: "list" | "parent"; before?: string }[] = []
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
let historyGates = 0
await mockOpenCodeServer(page, {
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
messageDelay: 50,
onMessages: (request) => {
if (request.sessionID === fixture.targetID && request.phase === "start")
requests.push({ type: "list", before: request.before })
},
beforeMessagesResponse: (request) => {
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
historyGates++
return history!.promise
},
onMessage: (request) => {
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
},
message: (sessionID, messageID) => {
if (sessionID !== fixture.targetID || messageID !== userID) return
return user
},
pageMessages: (sessionID, limit, before) => {
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
const start = Math.max(0, end - limit)
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const href = stressSessionHref(fixture.targetID)
await page.evaluate(
({ href, title }) => {
const link = document.createElement("a")
link.id = "parent-hydration-target"
link.href = href
link.textContent = title
document.body.append(link)
},
{ href, title: target.title },
)
const metrics = await measureSessionSwitch(page, {
destinationIDs: messages.map((message) => message.info.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
lastID,
requiredPartID: lastPartID,
requireBottomAnchor: false,
href,
switch: async () => {
await page.locator("#parent-hydration-target").click()
await expectSessionTitle(page, target.title)
},
}).finally(() => history?.resolve())
expect(metrics.firstCorrectObservedMs).not.toBeNull()
const requestCounts = {
list: requests.filter((request) => request.type === "list").length,
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
source: string[]
hasVisibleRows: boolean
last: boolean
requiredPartVisible?: boolean
bottomAnchorRequired?: boolean
bottomErrorPx?: number
review?: {
fileHost: boolean
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
sample.requiredPartVisible !== false &&
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
)
}
@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
async function installSessionSwitchProbe(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
input: {
destinationIDs: string[]
sourceIDs: string[]
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
href: string
},
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
samples.push({
observedAtMs,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
}
requestAnimationFrame(sample)
}, 0)
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
sample.requiredPartVisible !== false &&
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
)
)
})
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
export async function measureSessionSwitch(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
input: {
destinationIDs: string[]
sourceIDs: string[]
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
href: string
switch: () => Promise<void>
},
) {
const { switch: run, ...probe } = input
await installSessionSwitchProbe(page, probe)
await run()
await waitForStableSessionSwitch(page)
return collectSessionSwitchResult(page)
try {
await run()
await waitForStableSessionSwitch(page)
return await collectSessionSwitchResult(page)
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
})
}
}
export async function waitForStableTimeline(page: Page, lastID: string) {
@@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [{ id: "session" }],
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
return gate.promise
},
onMessages: (request) => events.push(request.phase),
pageMessages: () => {
events.push("page")
return { items: [] }
},
})
const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
expect(events).toEqual(["start", "before"])
const released = performance.now()
gate.resolve()
await response
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})
@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
expect(result.firstCorrectObservedMs).toBeNull()
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: [],
hasVisibleRows: true,
last: true,
requiredPartVisible: false,
bottomErrorPx: 0,
},
])
expect(result.firstCorrectObservedMs).toBeNull()
})
test("can measure content correctness without requiring a bottom anchor", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: [],
hasVisibleRows: true,
last: true,
requiredPartVisible: true,
bottomAnchorRequired: false,
},
])
expect(result.firstCorrectObservedMs).toBe(16)
})
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test"
import type { Page } from "@playwright/test"
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
function testPage(waitFailure?: Error) {
const stops: unknown[] = []
const page = {
evaluate: async (_callback: unknown, input?: unknown) => {
if (input) return
stops.push(undefined)
},
waitForFunction: async () => {
if (waitFailure) throw waitFailure
},
} as unknown as Page
return { page, stops }
}
function input(run: () => Promise<void>) {
return {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
href: "/session/destination",
switch: run,
}
}
test("stops sampling when the session switch fails", async () => {
const failure = new Error("switch failed")
const context = testPage()
await expect(
measureSessionSwitch(
context.page,
input(async () => Promise.reject(failure)),
),
).rejects.toBe(failure)
expect(context.stops).toHaveLength(1)
})
test("stops sampling when the stable wait fails", async () => {
const failure = new Error("stable wait failed")
const context = testPage(failure)
await expect(
measureSessionSwitch(
context.page,
input(async () => {}),
),
).rejects.toBe(failure)
expect(context.stops).toHaveLength(1)
})
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_panel_corner"
const directory = "C:/OpenCode/NewSessionPanelCorner"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({
viewport: { width: 935, height: 522 },
deviceScaleFactor: 1,
})
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_panel_corner",
worktree: directory,
vcs: "git",
name: "new-session-panel-corner",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
await expect(panel).toHaveCount(1)
const box = await panel.boundingBox()
if (!box) throw new Error("New-session panel bounds are unavailable")
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
const corners = await page.evaluate(
async ({ source, points }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
points: [
{ x: Math.floor(box.x), y: Math.floor(box.y) },
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
],
},
)
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
})
@@ -0,0 +1,146 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewOpenFile"
const projectID = "proj_review_open_file"
const sessionID = "ses_review_open_file"
const title = "Review open file"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [fileDiff("src/changed.ts")],
fileList: (path) => {
if (path) return []
return [
fileNode("README.md"),
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
const resultID = await result.getAttribute("id")
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
return {
name: path,
path,
absolute: `${directory}/${path}`,
type: "file",
ignored: false,
}
}
function fileDiff(file: string) {
return {
file,
before: "before\n",
after: "after\n",
additions: 1,
deletions: 1,
status: "modified",
}
}
@@ -0,0 +1,152 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewStatePersistence"
const projectID = "proj_review_state_persistence"
const sessionA = "ses_review_state_a"
const sessionB = "ses_review_state_b"
const titleA = "Alpha review state"
const titleB = "Beta review state"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("restores review mode and selected file per session", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click()
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
}
async function expectSelectedFile(page: Page, file: string) {
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
}
async function switchSession(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-state-persistence",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function diff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
const titleA = "Alpha session"
const titleB = "Beta session"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const diffs = Array.from({ length: 2_740 }, (_, index) =>
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
)
// Marks the review pane DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
@@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.getByRole("tab", { name: /Review/ })
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -80,16 +97,7 @@ async function setup(page: Page) {
default: { providerID: "opencode", modelID: "test" },
},
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
vcsDiff: [
{
file: "src/example.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
},
],
vcsDiff: diffs,
pageMessages: () => ({ items: [] }),
})
@@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
function fileDiff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
@@ -0,0 +1,263 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked"
const sessionID = "ses_review_terminal_stacked"
const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
100,
false,
),
),
]
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-terminal-stacked",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "review-terminal-stacked",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
})
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await treeViewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
await expect(lastFile).toBeVisible()
const bottomGap = await lastFile.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.bottom - element.getBoundingClientRect().bottom
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible()
}
async function expectMountedTree(page: Page, total: number) {
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
await expect(tree).toHaveAttribute("data-total-rows", String(total))
await expect
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
.toBeGreaterThan(0)
const state = await tree.evaluate((element) => ({
root: element.getBoundingClientRect().height,
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
}))
expect(state.viewport).toBeGreaterThan(0)
expect(state.root).toBeGreaterThan(0)
expect(state.rows).toBeGreaterThan(0)
expect(state.rows).toBeLessThanOrEqual(60)
}
async function expectStackGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
return {
review: review.getBoundingClientRect().height,
reviewParent: reviewParent.height,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}
@@ -0,0 +1,220 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import {
assistantMessage,
directory,
messageUpdated,
project,
session,
sessionID,
status,
textPart,
title,
userID,
userMessage,
} from "../performance/timeline-stability/fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const assistants = Array.from({ length: 14 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID,
created: 1700000001000 + index * 1_000,
completed: index < 13,
}),
)
const messages = [userMessage(), ...assistants]
const lastAssistant = assistants.at(-1)!
const lastPartID = assistants.at(-1)!.parts[0]!.id
const userPartID = `prt_${userID}_text`
const completed = {
...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
}
const scenarios = [
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
{
name: "interruption",
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
idleFirst: true,
interrupted: true,
},
] as const
test.use({ viewport: { width: 646, height: 1385 } })
for (const scenario of scenarios) {
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
const requests: { before?: string; phase: "start" | "end" }[] = []
const pages: { before?: string; limit: number }[] = []
const roots: { sessionID: string; messageID: string }[] = []
const sequence: string[] = []
const history = Promise.withResolvers<void>()
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session()],
sessionStatus: { [sessionID]: { type: "busy" } },
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
onMessages: (request) => {
requests.push(request)
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
},
onMessage: (request) => {
roots.push(request)
sequence.push(`message:${request.messageID}`)
},
message: (requestedSessionID, messageID) => {
if (requestedSessionID !== sessionID) return
return messages.find((item) => item.info.id === messageID)
},
pageMessages: (_, limit, before) => {
pages.push({ before, limit })
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
},
})
await page.addInitScript(
({ userPartID, lastPartID }) => {
const state = { armed: false, hidden: false, samples: 0, stop: false }
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect &&
!!view &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > view.top &&
rect.top < view.bottom
)
}
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
state.samples++
}
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
}
requestAnimationFrame(() => setTimeout(sample, 0))
},
{ userPartID, lastPartID },
)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 4)).toEqual([
"messages:start:latest",
"messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-2)!.info.id}`,
])
await page.evaluate(() => {
;(
window as Window & {
__historyRootProbe?: { armed: boolean }
}
).__historyRootProbe!.armed = true
})
await waitForProbeSamples(page, 0)
expect(await historyRootHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info)
const idle = status("idle")
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
const beforeEvent = await probeSamples(page)
await transport.send(event)
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
if (event === message && scenario.interrupted)
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
await waitForProbeSamples(page, beforeEvent)
const current = await timelineState(page)
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
}
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
expect(
await page.evaluate(() => {
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
.__historyRootProbe!
state.stop = true
return state.hidden
}),
).toBe(false)
})
}
function timelineState(page: Page) {
return page.evaluate(() => ({
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
rows: document.querySelectorAll("[data-timeline-key]").length,
}))
}
function probeSamples(page: Page) {
return page.evaluate(
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
)
}
async function waitForProbeSamples(page: Page, after: number) {
await page.waitForFunction(
(after) =>
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
after,
)
}
function historyRootHidden(page: Page) {
return page.evaluate(
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
)
}
@@ -0,0 +1,228 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const deviceScaleFactor of [1.25, 1.5]) {
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
const shellID = "prt_shell_outline"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
reducedMotion: true,
deviceScaleFactor,
})
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
const output = part.locator('[data-component="bash-output"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(output).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
if (!output) throw new Error("Shell output is unavailable")
const rowRect = element.getBoundingClientRect()
const outputRect = output.getBoundingClientRect()
// Match a rounded-down measurement at a fractional device-pixel phase.
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
element.style.transform = "translateY(0.25px)"
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
const style = getComputedStyle(output)
return {
outputWidth: outputRect.width,
outputHeight: outputRect.height,
borderColor: style.borderTopColor,
boxShadow: style.boxShadow,
clipMargin: getComputedStyle(element).overflowClipMargin,
}
})
await timeline.settle()
const clipped = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
})
expect(clipped).toBeCloseTo(0.49, 1)
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
const edges = await captureCardEdges(page, output)
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
expect(geometry.boxShadow).toBe("none")
expect(geometry.clipMargin).toBe("0.5px")
expect(edges.magenta.top).toBeGreaterThan(0.75)
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
})
}
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
if (!card) throw new Error("Patch card is unavailable")
const rowRect = element.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
const clipMargin = getComputedStyle(element).overflowClipMargin
const bottom = element.getBoundingClientRect().bottom
return {
overflow: card.getBoundingClientRect().bottom - bottom,
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
clipMargin,
cardWidth: cardRect.width,
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
expect(edges.luminance.top).toBeLessThan(245)
expect(edges.luminance.bottom).toBeLessThan(245)
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
expect(geometry.clipMargin).toBe("0.5px")
})
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
const secondUserID = "msg_outline_second_user"
await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/summary.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
},
}),
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_outline_second_assistant",
parentID: secondUserID,
created: 1700000011000,
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
clipMargin: getComputedStyle(element).overflowClipMargin,
})),
)
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
})
async function captureCardEdges(page: Page, card: Locator) {
const box = await card.boundingBox()
if (!box) throw new Error("Tool card bounds are unavailable")
const viewport = page.viewportSize()
if (!viewport) throw new Error("Viewport bounds are unavailable")
const screenshot = await page.screenshot()
return page.evaluate(
async ({ source, box, viewport }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
const scale = {
x: image.naturalWidth / viewport.width,
y: image.naturalHeight / viewport.height,
}
const rows = (candidates: number[]) => {
const left = Math.floor((box.x + 8) * scale.x)
const width = Math.floor((box.width - 16) * scale.x)
return candidates.map((row) => {
const pixels = context.getImageData(left, row, width, 1).data
const indexes = Array.from({ length: width }, (_, index) => index * 4)
return {
luminance:
indexes
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
.reduce((sum, value) => sum + value, 0) / width,
magenta:
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
.length / width,
}
})
}
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
const columns = new Uint32Array(image.naturalWidth)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
}
const top = box.y * scale.y
const bottom = (box.y + box.height) * scale.y
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
return {
box,
luminance: {
top: Math.min(...topRows.map((row) => row.luminance)),
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
},
magenta: {
top: Math.max(...topRows.map((row) => row.magenta)),
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
},
}
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
viewport,
box,
},
)
}
@@ -0,0 +1,89 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TerminalComposerFocus"
const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
test.use({ viewport: { width: 1440, height: 900 } })
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "terminal-composer-focus",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "terminal-composer-focus",
projectID,
directory,
title: "Terminal composer focus",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}),
)
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
await page.keyboard.type("x")
await expect(composer).toHaveText("")
await page.waitForTimeout(300)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.keyboard.type("a")
await expect(composer).toBeFocused()
await expect(composer).toHaveText("a")
})
@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "true")
await expect(panel).toHaveCount(0)
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
})
+1
View File
@@ -10,6 +10,7 @@
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
]
+37 -2
View File
@@ -1,7 +1,7 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig {
provider: unknown
@@ -11,7 +11,10 @@ export interface MockServerConfig {
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => unknown
onMessage?: (input: { sessionID: string; messageID: string }) => void
events?: () => unknown[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
@@ -19,6 +22,7 @@ export interface MockServerConfig {
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
}
@@ -52,6 +56,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
@@ -62,6 +67,23 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
if (path === "/file/content" && config.fileContent)
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
if (path === "/find/file" && config.findFiles)
return json(
route,
await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("dirs") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
}),
)
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
},
data: [],
})
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -72,6 +94,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, session ?? {})
}
const projectMatch = path.match(/^\/project\/([^/]+)$/)
if (projectMatch) return json(route, config.project)
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, message)
}
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
@@ -82,7 +116,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.13",
"version": "1.17.15",
"description": "",
"type": "module",
"exports": {
+124 -51
View File
@@ -9,9 +9,10 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
type Component,
createEffect,
@@ -32,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,9 +53,10 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -88,10 +90,14 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return <SessionPage />
return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
}
const TargetSessionRoute = () => {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
@@ -105,14 +111,47 @@ const TargetSessionRoute = () => {
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetSessionRouteContent />
</ServerSyncProvider>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@@ -126,16 +165,17 @@ function SelectedServerProviders(props: ParentProps) {
)
}
function LegacyServerLayout(props: ParentProps) {
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
@@ -144,7 +184,14 @@ function DraftRoute() {
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show>
</Show>
)
@@ -186,7 +233,7 @@ declare global {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
@@ -263,12 +310,14 @@ function DesktopCommands() {
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
</PermissionProvider>
@@ -277,16 +326,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps) {
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
@@ -318,8 +367,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale}>
@@ -347,7 +396,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
)
}
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -376,34 +425,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
const [startup] = createResource(async () => {
if (!props.startup) return true
await props.startup.catch((error) => {
console.error("[startup] startup gate failed", error)
})
return true
})
const startupChecking = createMemo(
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
)
const loading = createMemo(() => checking() || startupChecking())
return (
<Show
when={!checking()}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<>
<Show when={!checking()}>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
<Show when={loading()}>
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
</>
)
}
@@ -470,6 +530,8 @@ export function AppInterface(props: {
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
startup?: Promise<void>
serverScoped?: JSX.Element
}) {
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
@@ -491,7 +553,7 @@ export function AppInterface(props: {
>
<GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
@@ -500,14 +562,14 @@ export function AppInterface(props: {
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</TabsProvider>
)}
>
<Routes />
<Routes serverScoped={props.serverScoped} />
</Dynamic>
</Show>
</ConnectionGate>
@@ -517,13 +579,24 @@ export function AppInterface(props: {
)
}
function Routes() {
function Routes(props: { serverScoped?: JSX.Element }) {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route
component={(routeProps) => (
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
)}
>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -531,15 +604,15 @@ function Routes() {
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
function LegacyTargetSessionRoute() {
function NewLayoutLegacySessionRedirect() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
@@ -0,0 +1,325 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
type: "command" | "file" | "session"
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
return {
id: "file:" + path,
type: "file",
title: path,
category,
path,
}
}
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
const file = useFile()
const layout = useLayout()
const { tabs, view } = useSessionLayout()
return (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
onOpenFile?.(path)
tabs().setActive(value)
}
}
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const recentFileEntries = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = language.t("palette.group.files")
return order
.map((item) => file.pathFromTab(item))
.filter((path): path is string => {
if (!path || seen.has(path)) return false
seen.add(path)
return true
})
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const rootFileEntries = createMemo(() => {
const category = language.t("palette.group.files")
return file.tree
.children("")
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
openFile(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return {
language,
file,
commandEntries,
preferredCommandEntries,
recentFileEntries,
rootFileEntries,
sessions,
highlight,
select,
close: () => dialog.close(),
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -0,0 +1,220 @@
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
max-height: min(calc(100vh - 96px), 480px);
margin-top: max(48px, calc((100vh - 480px) / 2));
border-radius: 12px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 0;
padding: 0;
}
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -0,0 +1,221 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
createEffect(() => {
query()
visibleEntries()
setActive(0)
})
createEffect(() => {
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
}
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
value={query()}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
/>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
</DialogBody>
</Dialog>
)
}
function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
onActive: () => void
onSelect: () => void
}) {
return (
<button
type="button"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
props.onActive()
}}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onSelect}
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
</Match>
</Switch>
</button>
)
}
@@ -7,29 +7,174 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tag } from "@opencode-ai/ui/tag"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import {
type Accessor,
type Component,
createEffect,
createMemo,
createResource,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
const CUSTOM_ID = "_custom"
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const reset = () => setStore("selected", undefined)
return {
selected: () => store.selected,
select: (provider?: string) => setStore("selected", provider),
back: options.onBack ?? reset,
}
}
export const DialogConnectProvider: Component<{
directory?: Accessor<string | undefined>
controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => {
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const language = useLanguage()
const reset = controller.back
const back = { current: reset }
const select = (provider?: string) => {
back.current = reset
controller.select(provider)
}
return (
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Switch>
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm />
</Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
{(provider) => (
<ProviderConnection
provider={provider()}
directory={props.directory}
onBack={reset}
setBack={(handler) => (back.current = handler)}
/>
)}
</Match>
<Match when={true}>
<ProviderPicker directory={props.directory} onSelect={select} />
</Match>
</Switch>
</Dialog>
)
}
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
return undefined
}
return (
<List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small"
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
sortBy={(a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
const popular = popularGroup()
if (a.category === popular && b.category !== popular) return -1
if (b.category === popular && a.category !== popular) return 1
return 0
}}
onSelect={(x) => {
if (!x) return
props.onSelect(x.id)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === CUSTOM_ID}>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</Show>
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<Show when={i.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
)}
</List>
)
}
function ProviderConnection(props: {
provider: string
directory?: Accessor<string | undefined>
onBack: () => void
setBack: (handler: () => void) => void
}) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const providers = useProviders(props.directory)
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
})
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -364,21 +509,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
}
function goBack() {
if (methods().length === 1) {
all()
return
}
if (store.authorization) {
if (methods().length > 1 && store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
if (store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
all()
props.onBack()
}
props.setBack(goBack)
function MethodSelection() {
return (
<>
@@ -599,79 +738,67 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
}
return (
<Dialog
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={goBack}
aria-label={language.t("common.goBack")}
/>
}
>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</div>
</div>
<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>
<Match when={store.state === "pending"}>
<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.state === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</div>
</div>
</Dialog>
<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>
<Match when={store.state === "pending"}>
<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.state === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
)
}
@@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { type Accessor, batch, For } from "solid-js"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
type Props = {
back?: "providers" | "close"
directory?: Accessor<string | undefined>
onBack: () => void
}
export function DialogCustomProvider(props: Props) {
const language = useLanguage()
return (
<Dialog
class="h-full"
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={props.onBack}
aria-label={language.t("common.goBack")}
/>
}
transition
>
<CustomProviderForm />
</Dialog>
)
}
export function CustomProviderForm() {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
@@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
err: {},
})
const goBack = () => {
if (props.back === "close") {
dialog.close()
return
}
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
}
const addModel = () => {
setForm(
"models",
@@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) {
}
return (
<Dialog
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={goBack}
aria-label={language.t("common.goBack")}
/>
}
transition
>
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
{language.t("provider.custom.description.suffix")}
</p>
<div class="flex flex-col gap-4">
<TextField
autofocus
label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={(v) => setField("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined}
error={form.err.providerID}
/>
<TextField
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={(v) => setField("name", v)}
validationState={form.err.name ? "invalid" : undefined}
error={form.err.name}
/>
<TextField
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={(v) => setField("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined}
error={form.err.baseURL}
/>
<TextField
label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={(v) => setField("apiKey", v)}
/>
</div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
{language.t("provider.custom.description.suffix")}
</p>
<div class="flex flex-col gap-4">
<TextField
autofocus
label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={(v) => setField("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined}
error={form.err.providerID}
/>
<TextField
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={(v) => setField("name", v)}
validationState={form.err.name ? "invalid" : undefined}
error={form.err.name}
/>
<TextField
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={(v) => setField("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined}
error={form.err.baseURL}
/>
<TextField
label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={(v) => setField("apiKey", v)}
/>
</div>
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.name.label")}
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
aria-label={language.t("provider.custom.models.remove")}
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
{language.t("provider.custom.models.add")}
</Button>
</div>
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.key.label")}
hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setHeader(i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined}
error={h.err.key}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.value.label")}
hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setHeader(i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined}
error={h.err.value}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeHeader(i())}
disabled={form.headers.length <= 1}
aria-label={language.t("provider.custom.headers.remove")}
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.name.label")}
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
{language.t("provider.custom.headers.add")}
</Button>
</div>
<Button
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
aria-label={language.t("provider.custom.models.remove")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
{language.t("provider.custom.models.add")}
</Button>
</form>
</div>
</Dialog>
</div>
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.key.label")}
hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setHeader(i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined}
error={h.err.key}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.value.label")}
hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setHeader(i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined}
error={h.err.value}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeHeader(i())}
disabled={form.headers.length <= 1}
aria-label={language.t("provider.custom.headers.remove")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
{language.t("provider.custom.headers.add")}
</Button>
</div>
<Button
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button>
</form>
</div>
)
}
@@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { decode64 } from "@/utils/base64"
import { SettingsListV2 } from "./settings-v2/parts/list"
import { SettingsRowV2 } from "./settings-v2/parts/row"
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
@@ -1,329 +1,81 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
@@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (filesOnly()) {
const files = await file.searchFiles(query)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
@@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
: language.t("palette.search.placeholder"),
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={language.t("palette.empty")}
loadingMessage={language.t("common.loading")}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
@@ -452,7 +149,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
@@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
const providers = useProviders(directory)
const language = useLanguage()
const connect = (provider: string) => {
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
})
}
const connect = (provider: string) => openProviders(provider)
const all = () => openProviders()
let listRef: ListRef | undefined
const handleKeyDown = (e: KeyboardEvent) => {
@@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -92,6 +93,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -155,8 +157,8 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => {
close("provider")
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
const language = useLanguage()
@@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
)}
</For>
</MenuV2.RadioGroup>
@@ -503,8 +520,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const directory = () => decode64(local.slug())
const provider = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
@@ -1,87 +0,0 @@
import { type Accessor, Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { useLanguage } from "@/context/language"
import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom"
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
const dialog = useDialog()
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
}
return (
<Dialog title={language.t("command.provider.connect")} transition>
<List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small"
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
sortBy={(a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
const popular = popularGroup()
if (a.category === popular && b.category !== popular) return -1
if (b.category === popular && a.category !== popular) return 1
return 0
}}
onSelect={(x) => {
if (!x) return
if (x.id === CUSTOM_ID) {
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
return
}
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === CUSTOM_ID}>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</Show>
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<Show when={i.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
)}
</List>
</Dialog>
)
}
@@ -1,22 +1,35 @@
import { Component } from "solid-js"
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => {
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3">
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
<SettingsProviders onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
describe("file tree v2 model", () => {
test("builds sorted depth-first rows", () => {
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8)
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
["docs", "directory", 0],
["docs/guide.md", "file", 1],
["src", "directory", 0],
["src/lib", "directory", 1],
["src/lib/a.ts", "file", 2],
["src/lib/b.ts", "file", 2],
["src/z.ts", "file", 1],
["README.md", "file", 0],
])
})
test("omits descendants of collapsed directories", () => {
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
"src",
"src/lib",
"src/z.ts",
])
})
test("normalizes separators and duplicate paths", () => {
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true)
expect(model.total).toBe(4)
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
})
test("supports paths deeper than the legacy recursion limit", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
})
})
@@ -0,0 +1,77 @@
import type { FileNode } from "@opencode-ai/sdk/v2"
export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
total: number
}
export type FileTreeV2Node = FileNode & { originalPath: string }
export type FileTreeV2Row = {
node: FileTreeV2Node
level: number
}
export function normalizeFileTreeV2Path(value: string) {
return value
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "")
.replace(/\/{2,}/g, "/")
}
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
const nodes = new Map<string, FileTreeV2Node>()
paths.forEach((value) => {
const file = normalizeFileTreeV2Path(value)
if (!file) return
const parts = file.split("/")
parts.forEach((name, index) => {
const path = parts.slice(0, index + 1).join("/")
if (nodes.has(path)) return
nodes.set(path, {
name,
path,
absolute: path,
type: index === parts.length - 1 ? "file" : "directory",
ignored: false,
originalPath: index === parts.length - 1 ? value : path,
})
})
})
const children = new Map<string, FileTreeV2Node[]>()
nodes.forEach((node) => {
const index = node.path.lastIndexOf("/")
const parent = index === -1 ? "" : node.path.slice(0, index)
const list = children.get(parent)
if (list) list.push(node)
else children.set(parent, [node])
})
children.forEach((nodes) =>
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
}),
)
return { children, total: nodes.size }
}
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
const rows: FileTreeV2Row[] = []
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
while (stack.length > 0) {
const row = stack.pop()!
rows.push(row)
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
const children = model.children.get(row.node.path) ?? []
for (let index = children.length - 1; index >= 0; index--) {
stack.push({ node: children[index]!, level: row.level + 1 })
}
}
return rows
}
+145 -298
View File
@@ -1,95 +1,26 @@
import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import {
createEffect,
createMemo,
createSignal,
For,
Match,
on,
Show,
splitProps,
Switch,
untrack,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import {
dirsToExpand,
pathToFileUrl,
shouldListRoot,
visibleKind,
withFileDragImage,
type Filter,
type Kind,
} from "@/components/file-tree"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
export type { Kind } from "@/components/file-tree"
const MAX_DEPTH = 128
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
const nodes = children(path)
if (!current) return nodes
const parent = (item: string) => {
const idx = item.lastIndexOf("/")
if (idx === -1) return ""
return item.slice(0, idx)
}
const leaf = (item: string) => {
const idx = item.lastIndexOf("/")
return idx === -1 ? item : item.slice(idx + 1)
}
const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
out.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return out
}
const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) {
@@ -123,7 +54,6 @@ const FileTreeNodeV2 = (
active?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
@@ -133,18 +63,18 @@ const FileTreeNodeV2 = (
"active",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
const kind = () => local.kinds?.get(local.node.path)
return (
<Dynamic
component={local.as ?? "div"}
data-slot="file-tree-v2-row"
data-path={local.node.path}
data-selected={local.node.path === local.active ? "" : undefined}
data-ignored={local.node.ignored ? "" : undefined}
classList={{
@@ -177,244 +107,161 @@ const FileTreeNodeV2 = (
)
}
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
// synthesized from that list) for nested content to appear.
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For>
)
}
export default function FileTreeV2(props: {
path: string
active?: string
level?: number
allowed?: readonly string[]
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => {
if (props._filter) return props._filter
const allowed = props.allowed
if (!allowed) return
const files = new Set(allowed)
const dirs = new Set<string>()
for (const item of allowed) {
const parts = item.split("/")
const parents = parts.slice(0, -1)
for (const [idx] of parents.entries()) {
const dir = parents.slice(0, idx + 1).join("/")
if (dir) dirs.add(dir)
}
}
return { files, dirs }
const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => virtualScrollElement(root()),
initialRect: { width: 0, height: 600 },
estimateSize: () => 28,
gap: 2,
overscan: 10,
get getItemKey() {
const current = rows()
return (index: number) => current[index]?.node.path ?? index
},
rangeExtractor: (range) => {
const indexes = defaultRangeExtractor(range)
const path = focused()
const index = path ? rows().findIndex((row) => row.node.path === path) : -1
if (index < 0 || indexes.includes(index)) return indexes
return [...indexes, index].sort((a, b) => a - b)
},
})
const marks = createMemo(() => {
if (props._marks) return props._marks
const out = new Set<string>(props.kinds?.keys() ?? [])
if (out.size === 0) return
return out
})
const kinds = createMemo(() => {
if (props._kinds) return props._kinds
return props.kinds
})
const deeps = createMemo(() => {
if (props._deeps) return props._deeps
const out = new Map<string, number>()
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
}
return out
})
createEffect(() => {
const current = filter()
const dirs = dirsToExpand({
level,
filter: current,
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
const path = active()
if (!path) return
const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return
queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
})
// Nodes come from the `allowed` filter; skip listing so directories that only
// exist on the diff's base branch do not each fail with an error toast.
for (const dir of dirs) file.tree.expand(dir, { list: false })
})
createEffect(
on(
() => props.path,
(path) => {
const dir = untrack(() => file.tree.state(path))
if (!shouldListRoot({ level, dir })) return
void file.tree.list(path)
},
{ defer: false },
),
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
return (
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
// an outer group with the same name to widen the hover area.
<div data-component="file-tree-v2" class="group/file-tree-v2">
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
return (
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
data-scope="file-tree-v2"
forceMount={false}
open={expanded()}
onOpenChange={(open) =>
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
}
>
<Collapsible.Trigger>
<FileTreeNodeV2
node={node}
level={level}
active={props.active}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
>
<div
data-slot="file-tree-v2-chevron"
data-expanded={expanded() ? "" : undefined}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Collapsible.Trigger>
<Show when={hasChildren()}>
<Collapsible.Content class="relative">
<div
classList={{
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
}}
style={`left: ${guideLineLeft(level)}px`}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTreeV2
path={node.path}
level={level + 1}
allowed={props.allowed}
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={model().total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
<For each={virtualRowKeys()}>
{(key) => (
<Show when={virtualItemByKey().get(key)}>
{(item) => (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<Show when={rowByKey().get(key as string)}>
{(row) => (
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content>
</Show>
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<FileTreeNodeV2
node={node}
level={level}
active={props.active}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
>
<Show when={level > 0}>
<div class="w-4 shrink-0" />
</Show>
<Show
when={!node.ignored}
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
>
<span class="filetree-iconpair size-4">
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</Show>
</FileTreeNodeV2>
</Match>
</Switch>
)
}}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() =>
props.onFileClick?.({
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
}
>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
onClick={() =>
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Show>
)}
</Show>
</div>
)}
</Show>
)}
</For>
</div>
)
@@ -201,6 +201,7 @@ export default function FileTree(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
onFileDoubleClick={props.onFileDoubleClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
onDblClick={() => props.onFileDoubleClick?.(node)}
>
<div class="w-4 shrink-0" />
<Switch>
+35 -2
View File
@@ -1,4 +1,4 @@
import { Show, type Component } from "solid-js"
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,7 +23,18 @@ type ModelInfo = {
}
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
props,
) => {
const language = useLanguage()
const sourceName = (model: ModelInfo) => {
const value = `${model.id} ${model.name}`.toLowerCase()
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
const input = props.model.capabilities.input
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
: language.t("model.tooltip.reasoning.none")
}
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
if (props.v2) {
return (
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
return (
<div class="flex flex-col gap-1 py-1">
@@ -1,7 +1,7 @@
import { Component, For, Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt"
@@ -27,7 +27,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item)
return (
<Tooltip
<TooltipV2
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
@@ -78,7 +78,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</Tooltip>
</TooltipV2>
)
}}
</For>
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
const settings = useSettings()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -31,6 +31,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
const OPEN_APPS = [
"vscode",
@@ -284,10 +285,9 @@ export function SessionHeader() {
}
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
const rightMount = useTitlebarRightMount()
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
setRightMount(document.getElementById("opencode-titlebar-right"))
})
return (
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5 min-w-0">
<Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
</span>
</Show>
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
{getFilename(props.path)}
</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
export function SortableTab(props: {
tab: string
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
@@ -0,0 +1,271 @@
import type { JSX } from "solid-js"
import { Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTabV2(props: {
terminal: LocalPTY
index: () => number
newLayout: boolean
onClose?: () => void
}): JSX.Element {
const terminal = useTerminal()
const language = useLanguage()
const sortable = useSortable({
get id() {
return props.terminal.id
},
get index() {
return props.index()
},
})
const [store, setStore] = createStore({
editing: false,
title: props.terminal.title,
menuOpen: false,
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false
return isDefaultTerminalTitle(props.terminal.title, number)
}
const label = () => {
language.locale()
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
const number = props.terminal.titleNumber
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
if (props.terminal.title) return props.terminal.title
return language.t("terminal.title")
}
const close = () => {
const count = terminal.all().length
void terminal.close(props.terminal.id)
if (count === 1) {
props.onClose?.()
}
}
const focus = () => {
if (store.editing) return
terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
}
const edit = (e?: Event) => {
if (e) {
e.stopPropagation()
e.preventDefault()
}
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
}
const save = () => {
if (!store.blurEnabled) return
const value = store.title.trim()
if (value && value !== props.terminal.title) {
terminal.update({ id: props.terminal.id, title: value })
}
setStore("editing", false)
}
const keydown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
save()
return
}
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
}
}
const menu = (e: MouseEvent) => {
e.preventDefault()
setStore("menuPosition", { x: e.clientX, y: e.clientY })
setStore("menuOpen", true)
}
createEffect(() => {
if (!store.editing) return
if (!input) return
input.focus()
input.select()
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
blurFrame = requestAnimationFrame(() => {
blurFrame = undefined
setStore("blurEnabled", true)
})
})
onCleanup(() => {
if (blurFrame === undefined) return
cancelAnimationFrame(blurFrame)
})
return (
<div
ref={sortable.ref}
class="outline-none focus:outline-none focus-visible:outline-none"
classList={{
"h-full flex items-center": props.newLayout,
"h-full": !props.newLayout,
}}
>
<Show
when={props.newLayout}
fallback={
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
onMouseDown={(e) => e.preventDefault()}
onContextMenu={menu}
class="!shadow-none"
classes={{
button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
}}
closeButton={
<IconButton
icon="close"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
close()
}}
aria-label={language.t("terminal.close")}
/>
}
>
<span onDblClick={edit} classList={{ invisible: store.editing }}>
{label()}
</span>
</Tabs.Trigger>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
onBlur={save}
onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()}
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
/>
</div>
</Show>
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="fixed"
style={{
left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`,
}}
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
>
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
<Icon name="edit" class="w-4 h-4 mr-2" />
{language.t("common.rename")}
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={close}>
<Icon name="close" class="w-4 h-4 mr-2" />
{language.t("common.close")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
}
>
<MenuV2.Context>
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
closeButton={
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={(e) => {
e.stopPropagation()
close()
}}
aria-label={language.t("terminal.close")}
/>
}
hideCloseButton
onMiddleClick={close}
>
<span
class="truncate"
data-slot="terminal-tab-title"
onDblClick={edit}
classList={{ invisible: store.editing }}
>
{label()}
</span>
</Tabs.Trigger>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
<input
ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
onBlur={save}
onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()}
class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
/>
</div>
</Show>
</MenuV2.Context.Trigger>
<MenuV2.Context.Portal>
<MenuV2.Context.Content
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
>
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
</MenuV2.Context.Content>
</MenuV2.Context.Portal>
</MenuV2.Context>
</Show>
</div>
)
}
@@ -440,11 +440,6 @@ export const SettingsGeneral: Component = () => {
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -471,11 +466,6 @@ export const SettingsGeneral: Component = () => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -5,24 +5,18 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
import { SettingsListV2 } from "./settings-v2/parts/list"
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
const IconButtonV2 = lazy(() =>
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
)
const TextInputV2 = lazy(() =>
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
)
const SettingsListV2 = lazy(() =>
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
)
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
@@ -28,20 +27,26 @@ const PROVIDER_NOTES = [
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component = () => {
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
return (
<SettingsServerScope>
<SettingsProvidersContent />
<SettingsProvidersContent onBack={props.onBack} />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component = () => {
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
@@ -206,14 +211,7 @@ const SettingsProvidersContent: Component = () => {
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</Button>
</div>
@@ -239,7 +237,7 @@ const SettingsProvidersContent: Component = () => {
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" />)
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -250,9 +248,7 @@ const SettingsProvidersContent: Component = () => {
<Button
variant="ghost"
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
onClick={() => connect()}
>
{language.t("dialog.provider.viewAll")}
</Button>
@@ -1,4 +1,4 @@
import { Component } from "solid-js"
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
@@ -10,16 +10,30 @@ import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog"
export const DialogSettings: Component<{
sessionID?: string
defaultValue?: string
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
}
return (
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
<TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
<TabsV2
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="settings-v2"
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full">
@@ -73,7 +87,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
<SettingsProvidersV2 onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
@@ -422,11 +422,6 @@ export const SettingsGeneralV2: Component<{
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -454,11 +449,6 @@ export const SettingsGeneralV2: Component<{
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "../dialog-connect-provider"
import { DialogSelectProvider } from "../dialog-select-provider"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
@@ -30,12 +29,18 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => {
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
@@ -206,14 +211,7 @@ export const SettingsProvidersV2: Component = () => {
</Show>
</div>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
<ButtonV2 size="normal" variant="neutral" icon="plus" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</ButtonV2>
</div>
@@ -241,7 +239,7 @@ export const SettingsProvidersV2: Component = () => {
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" />)
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -249,13 +247,7 @@ export const SettingsProvidersV2: Component = () => {
</div>
</SettingsListV2>
<button
type="button"
class="settings-v2-providers-view-all"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
>
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
{language.t("dialog.provider.viewAll")}
</button>
</div>
@@ -246,18 +246,23 @@ export function TabNavItem(props: {
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
</span>
)}
</Show>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 400
const OPEN_DELAY = 2_000
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
const sortableTransition = { duration: 0 }
function SessionTabSlot(props: {
tab: SessionTab
id: string
@@ -42,6 +40,7 @@ function SessionTabSlot(props: {
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
@@ -73,8 +72,10 @@ function SessionTabSlot(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!value || !current) return
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
@@ -88,7 +89,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() }}
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
@@ -97,7 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
+31 -3
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -60,6 +60,12 @@ export type TitlebarUpdate = {
install: () => void
}
export function useTitlebarRightMount() {
const [mount, setMount] = createSignal<HTMLElement | null>(null)
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
return mount
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
const layout = useLayout()
const platform = usePlatform()
@@ -328,6 +334,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return
}
if (route.type === "home") {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
@@ -374,9 +395,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: "mod+w",
hidden: true,
onSelect: () => {
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
},
},
{
id: "tab.reopenClosed",
category: language.t("command.category.file"),
title: language.t("command.tab.reopenClosed"),
keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(),
},
{
id: `tab.prev`,
category: "tab",
@@ -464,7 +492,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.removeTab(index)
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { virtualScrollElement } from "./virtual-scroll-element"
test("resolves the connected viewport that owns the virtual root", () => {
const stale = document.createElement("div")
stale.className = "scroll-view__viewport"
const viewport = document.createElement("div")
viewport.className = "scroll-view__viewport"
const root = document.createElement("div")
viewport.append(root)
document.body.append(viewport)
expect(virtualScrollElement(root)).toBe(viewport)
expect(virtualScrollElement(root)).not.toBe(stale)
viewport.remove()
expect(virtualScrollElement(root)).toBeNull()
})
@@ -0,0 +1,4 @@
export function virtualScrollElement(root: HTMLElement | undefined) {
if (!root?.isConnected) return null
return root.closest<HTMLDivElement>(".scroll-view__viewport")
}
+40
View File
@@ -0,0 +1,40 @@
import type { SessionTab, Tab } from "./tabs"
export type ClosedTab = {
tab: SessionTab
index: number
}
const CLOSED_TAB_LIMIT = 25
// Only session tabs are recorded; closing a draft tab deletes its persisted
// state, so a reopened draft would come back empty anyway.
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
if (tab.type !== "session") return stack
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
}
// Pops the most recently closed tab that is not open again,
// discarding stale entries along the way.
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
const remaining = [...stack]
while (remaining.length) {
const entry = remaining.pop()
if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining }
}
return { stack: remaining }
}
export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) {
const removed = new Set(sessionIDs)
return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId))
}
export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
if (!active) return undefined
return tabs[index + 1] ?? tabs[index - 1] ?? null
}
function isOpen(tabs: Tab[], tab: SessionTab) {
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
}
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { upsertCommandRegistration } from "./command"
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
@@ -23,3 +23,19 @@ describe("upsertCommandRegistration", () => {
expect(next[1]?.options).toBe(one)
})
})
describe("resolveKeybindOption", () => {
test("prefers a matching contextual command over the global fallback", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => true }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual)
})
test("uses the global fallback outside the command context", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => false }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+16 -5
View File
@@ -82,10 +82,15 @@ export interface CommandOption {
suggested?: boolean
disabled?: boolean
hidden?: boolean
when?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
@@ -334,7 +339,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const keymap = createMemo(() => {
const map = new Map<string, CommandOption>()
const map = new Map<string, CommandOption[]>()
for (const option of options()) {
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
if (option.disabled) continue
@@ -344,8 +349,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
for (const kb of keybinds) {
if (!kb.key) continue
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
if (map.has(sig)) continue
map.set(sig, option)
const existing = map.get(sig)
if (existing) {
existing.push(option)
continue
}
map.set(sig, [option])
}
}
return map
@@ -374,7 +383,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
const option = resolveKeybindOption(keymap().get(sig), event)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
@@ -383,17 +392,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (isPalette) {
event.preventDefault()
event.stopPropagation()
showPalette()
return
}
if (!option) return
event.preventDefault()
event.stopPropagation()
option.onSelect?.("keybind")
}
onMount(() => {
makeEventListener(document, "keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown, { capture: true })
})
function register(cb: () => CommandOption[]): void
+8 -4
View File
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
const search = (query: string, dirs: "true" | "false") =>
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.client.find.files({ query, dirs })
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
(error) => {
if (options?.signal?.aborted) throw error
return []
},
)
const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) => search(query, "false"),
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
search(query, "false", options),
searchFilesAndDirectories: (query: string) => search(query, "true"),
}
},
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import {
SESSION_OPEN_FILE_TAB,
closeSessionTab,
openSessionTab,
previewSessionTab,
type SessionTabState,
} from "./layout-tabs"
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
tabs: { all, active },
preview,
})
describe("previewSessionTab", () => {
test("appends the Open File placeholder", () => {
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
)
})
test("replaces the current preview in place", () => {
expect(
previewSessionTab(
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://a.ts",
),
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
})
test("activates a durable tab without duplicating it", () => {
expect(
previewSessionTab(
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
})
test("replaces a restored Open File placeholder", () => {
expect(
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
})
})
describe("openSessionTab", () => {
test("pins the current preview", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
state(["file://a.ts"], "file://a.ts"),
)
})
test("replaces a preview with a directly opened file", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
state(["file://b.ts"], "file://b.ts"),
)
})
test("keeps the preview when switching to Review", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
state(["file://a.ts"], "review", "file://a.ts"),
)
})
test("replaces a restored Open File placeholder with a direct open", () => {
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
)
})
})
describe("closeSessionTab", () => {
test("clears preview metadata and selects the left neighbor", () => {
expect(
closeSessionTab(
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
})
})
+103
View File
@@ -0,0 +1,103 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export type SessionTabs = {
active?: string
all: string[]
}
export type SessionTabState = {
tabs: SessionTabs
preview?: string
}
const sessionTabPreview = (current: SessionTabState) =>
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
preview: tab,
}
}
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
if (tab === "review") {
return {
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
preview,
}
}
if (tab === "context") {
return {
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
preview,
}
}
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab } }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
}
}
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
if (tab === "review") {
if (current.tabs.active !== tab) return current
return {
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
preview: current.preview,
}
}
const all = current.tabs.all.filter((item) => item !== tab)
const preview = current.preview === tab ? undefined : current.preview
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
const index = current.tabs.all.indexOf(tab)
return {
tabs: {
all,
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
},
preview,
}
}
+82 -39
View File
@@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -55,14 +56,11 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
@@ -79,6 +77,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
@@ -87,14 +86,6 @@ export type LayoutRoute =
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
return { all, active: tab }
}
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
@@ -308,6 +299,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
sessionTabPreview: {} as Record<string, string | undefined>,
})
const MAX_SESSION_KEYS = 50
@@ -366,6 +358,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
scroll.drop(drop)
dropSessionState(drop)
setEphemeral(
"sessionTabPreview",
produce((draft) => {
for (const key of drop) delete draft[key]
}),
)
for (const key of drop) {
usage.used.delete(key)
@@ -791,6 +789,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
const mode = s().reviewMode
if (mode === "git" || mode === "branch" || mode === "turn") return mode
})
const reviewFile = createMemo(() => {
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@@ -874,6 +880,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
mode: reviewMode,
setMode(mode: ReviewChangeMode) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
prune(session)
return
}
if (current.reviewMode === mode) return
setStore("sessionView", session, "reviewMode", mode)
prune(session)
},
file: reviewFile,
setFile(file: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewFile: file })
prune(session)
return
}
if (current.reviewFile === file) return
setStore("sessionView", session, "reviewFile", file)
prune(session)
},
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()
@@ -945,10 +977,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
batch(() => {
setStore("sessionTabs", session, next.tabs)
setEphemeral("sessionTabPreview", session, next.preview)
})
}
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
@@ -961,40 +1000,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
batch(() => {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
const preview = ephemeral.sessionTabPreview[session]
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
})
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
apply(
session,
openSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
previewTab(tab: string) {
const session = key()
apply(
session,
previewSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (tab === "review") {
if (current.active !== tab) return
setStore("sessionTabs", session, "active", current.all[0])
return
}
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", next)
})
apply(
session,
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
)
},
move(tab: string, to: number) {
const session = key()
@@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({
})
type UserMessage = Extract<Message, { role: "user" }>
type AssistantMessage = Extract<Message, { role: "assistant" }>
type TextPart = Extract<Part, { type: "text" }>
type MessageResponse = {
data: { info: Message; parts: Part[] }[]
response: { headers: Headers }
}
type SingleMessageResponse = { data: MessageResponse["data"][number] }
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
id,
@@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
...input,
})
const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
id,
sessionID: "child",
role: "assistant",
time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
parentID,
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...input,
})
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
id: "part",
sessionID: "child",
@@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
})
const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
@@ -71,6 +91,40 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
})
}
function rootMessageClient(
pages: Array<MessageResponse | Promise<MessageResponse>>,
roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
) {
let pageIndex = 0
let rootIndex = 0
const requests: unknown[] = []
const rootRequests: unknown[] = []
const rootWaiting = new Map<number, () => void>()
const client = {
session: {
get: async () => ({ data: session("child", "root") }),
messages: (input: unknown) => {
requests.push(input)
return pages[pageIndex++]
},
message: (input: unknown) => {
rootRequests.push(input)
rootWaiting.get(rootRequests.length)?.()
rootWaiting.delete(rootRequests.length)
return roots[rootIndex++]
},
},
} as unknown as OpencodeClient
return Object.assign(client, {
requests,
rootRequests,
rootRequested(count: number) {
if (rootRequests.length >= count) return Promise.resolve()
return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
},
})
}
const retryImmediately: typeof retry = async (task, options = {}) => {
const attempts = options.attempts ?? 3
for (let attempt = 0; ; attempt++) {
@@ -124,6 +178,238 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([])
})
test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
})
test("does not let an optimistic user suppress initial root backfill", async () => {
const user = userMessage("message-1")
const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([user, ...assistants])
})
test("backfills the parent of fetched assistants when another user is cached", async () => {
const unrelated = userMessage("message-0", { time: { created: 0 } })
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response([{ info: unrelated, parts: [] }]),
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true })
expect(client.requests).toHaveLength(2)
expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
})
test("preserves cached history between an injected parent and the page boundary", async () => {
const user = userMessage("message-1")
const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([user, cached, assistant])
})
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const stalePart = textPart(stale.id, { text: "stale" })
const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [freshPart])],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([freshPart])
})
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [refreshed])],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
})
test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const client = rootMessageClient([pending.promise], [])
const store = createServerSession(client)
const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
await loading
expect(client.rootRequests).toEqual([])
expect(store.data.message.child).toEqual([user, assistant])
})
test("uses a successful retry over events received by a failed backfill attempt", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const live = { ...user, agent: "stale" }
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry"))
await loading
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(2)
expect(store.data.message.child).toEqual([user, ...assistants])
})
test("preserves newer-page events across a failed parent retry", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 }
const client = rootMessageClient(
[response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry"))
await loading
expect(store.data.message.child).toEqual([user, live])
})
test("preserves unrelated message events across a failed parent retry", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient(
[response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry"))
await loading
expect(store.data.message.child).toEqual([user, assistant, live])
})
test("preserves newer-page part events across a failed parent retry", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" }
const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
failed.reject(new Error("retry"))
await loading
expect(store.data.part[assistant.id]).toEqual([live])
})
test("merges live events into the initial page", async () => {
const pending = deferredResponse()
const user = userMessage("message-1")
@@ -905,6 +1191,26 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([latest])
})
test("does not scan cached messages for user roots during history prepend", async () => {
const guard = { active: false }
const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
get(target, property, receiver) {
if (guard.active && property === "role") throw new Error("cached role accessed")
return Reflect.get(target, property, receiver)
},
})
const older = userMessage("message-1")
const store = createServerSession(
messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
)
await store.sync("child")
guard.active = true
await store.history.loadMore("child")
expect(store.data.message.child).toEqual([older, latest])
})
test("preserves loaded history during an incomplete refresh", async () => {
const older = userMessage("message-1")
const latest = userMessage("message-2", { time: { created: 2 } })
+109 -30
View File
@@ -54,6 +54,11 @@ type MessageLoadState = {
clearedMessageParts: Set<string>
}
type MessageLoadBaseline = Pick<
MessageLoadState,
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
>
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session]
@@ -347,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
load.touchedParts.set(messageID, new Set([partID]))
}
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => {
load.touchedMessages.clear()
load.retainedMessages.clear()
load.touchedParts.clear()
@@ -380,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
baseline?.touchedParts.forEach((parts, messageID) => {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
})
}
const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({
touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)),
retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)),
touchedParts: new Map(
[...load.touchedParts]
.filter(([messageID]) => messageID !== exclude)
.map(([messageID, parts]) => [messageID, new Set(parts)]),
),
clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)),
})
const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
const evicted = new Set(sessionIDs)
@@ -460,6 +484,18 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.message({ sessionID, messageID })
})
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
return {
message: cleanMessage(response.data.info),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
}
}
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
@@ -578,36 +614,79 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
messageLoads.set(sessionID, load)
setMeta("loading", sessionID, true)
let applied = false
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
.then((page) => {
if (generations.get(sessionID) !== active) return
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
const preserveUnfetched =
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
page,
messageLoads.get(sessionID) === load ? load : undefined,
preserveUnfetched,
mode !== "prepend",
)
applied = true
})
.finally(() => {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
if (generations.get(sessionID) !== active) return
const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[]
if (mode !== "prepend") {
const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? [])
.filter((message) => {
if (message.role !== "user") return false
const item = optimistic.get(sessionID)?.get(message.id)
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
})
.map((message) => message.id),
])
const parentIDs = [
...new Set(
page.session.flatMap((message) =>
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
),
),
]
for (const parentID of parentIDs) {
if (generations.get(sessionID) !== active) break
const parent = await fetchMessage(sessionID, parentID, () =>
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
)
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
parents.push(parent)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
})
}
if (generations.get(sessionID) !== active) return
const result =
mode === "prepend"
? page
: {
...page,
session: merge(
page.session,
parents.map((parent) => parent.message),
),
part: merge(
page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
),
}
const preserveUnfetched =
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
result,
messageLoads.get(sessionID) === load ? load : undefined,
preserveUnfetched,
mode !== "prepend",
)
applied = true
} finally {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
}
}
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
+4 -1
View File
@@ -312,7 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
}
const isReady = createMemo(() => ready() && !!state.active)
const isReady = Object.assign(
createMemo(() => ready() && !!state.active),
{ promise: ready.promise },
)
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
+81
View File
@@ -1,6 +1,15 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import type { ServerConnection } from "./server"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
}
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
@@ -22,3 +31,75 @@ describe("tab memory", () => {
})
})
})
describe("closed tab stack", () => {
test("records session tabs with their index", () => {
const stack = pushClosedTab([], sessionTab("a"), 2)
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
})
test("ignores draft tabs", () => {
const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
expect(pushClosedTab([], draft, 0)).toEqual([])
})
test("caps the stack size", () => {
const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
(acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
[],
)
expect(stack).toHaveLength(25)
expect(stack[0]?.tab.sessionId).toBe("s5")
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
})
test("pops the most recently closed tab", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
]
const result = takeClosedTab(stack, [])
expect(result.entry?.tab.sessionId).toBe("b")
expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
})
test("skips entries whose tab is already open", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
]
const result = takeClosedTab(stack, [sessionTab("b")])
expect(result.entry?.tab.sessionId).toBe("a")
expect(result.stack).toEqual([])
})
test("returns no entry when everything is open or empty", () => {
expect(takeClosedTab([], []).entry).toBeUndefined()
const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
expect(result.entry).toBeUndefined()
expect(result.stack).toEqual([])
})
test("purges removed sessions", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
]
expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }])
})
test("does not navigate when a background tab closes", () => {
const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")]
expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined()
expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c"))
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
+91 -5
View File
@@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
export type SessionTab = {
type: "session"
@@ -27,6 +28,11 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
export type TabInfo = {
title?: string
directory?: string
}
type RecentTab = {
key?: string
}
@@ -63,6 +69,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const params = useParams()
const navigate = useNavigate()
@@ -87,10 +95,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
}
const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
const apply = () => setClosed((stack) => update(stack))
if (closedReady()) {
apply()
return
}
void closedReady.promise?.then(apply)
}
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
const removeInfo = (key: string) => {
if (!info[key]) return
setInfo(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -99,11 +125,26 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
if (!servers.has(tab.server)) {
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
}
}
setStore(() => next)
}
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
const keys = new Set(next.map(tabKey))
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
})
createEffect(() => {
if (!closedReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = closed.filter((entry) => servers.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
const navigateTab = (tab: Tab) => {
@@ -117,7 +158,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
closing.add(key)
void startTransition(() => {
setStore(
@@ -125,11 +166,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab === null) {
setRecentKey(undefined)
navigate("/")
}
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -201,23 +245,57 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeDraftPersisted(draftID)
},
removeTab,
// User-initiated close: records the tab so it can be reopened.
// Cleanup paths (missing sessions, archive, server removal) go through
// removeTab and friends directly and are not recorded.
closeTab(index: number) {
const tab = store[index]
if (!tab) return
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
removeTab(index)
},
reopenClosedTab() {
if (!closedReady()) {
void closedReady.promise?.then(() => actions.reopenClosedTab())
return
}
const result = takeClosedTab(closed, store)
if (result.stack.length === closed.length) return
setClosed(() => result.stack)
const entry = result.entry
if (!entry) return
const index = Math.min(entry.index, store.length)
void startTransition(() => {
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
tabs.splice(index, 0, entry.tab)
}),
)
navigateTab(entry.tab)
})
},
removeSessionTab(input: Omit<SessionTab, "type">) {
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
const index = store.findIndex(
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
)
if (index !== -1) removeTab(index)
},
removeServer(key: ServerConnection.Key) {
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
const removed = store
.filter(
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
@@ -265,6 +343,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.directory }
const current = info[key]
if (current?.title === next.title && current.directory === next.directory) return
setInfo(key, next)
},
select: navigateTab,
remember(tab: Tab) {
@@ -289,6 +375,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, ready, recentReady }
return { ...actions, store, info, ready, recentReady }
},
})
+148 -2
View File
@@ -21,6 +21,9 @@ export const dict = {
"theme.scheme.dark": "داكن",
"command.sidebar.toggle": "تبديل الشريط الجانبي",
"command.project.open": "فتح مشروع",
"command.project.previous": "المشروع السابق",
"command.project.next": "المشروع التالي",
"command.project.index": "التبديل إلى المشروع {{index}}",
"command.provider.connect": "اتصال بموفر",
"command.server.switch": "تبديل الخادم",
"command.settings.open": "فتح الإعدادات",
@@ -39,6 +42,7 @@ export const dict = {
"command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
"command.context.addSelection": "إضافة التحديد إلى السياق",
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
"command.input.focus": "التركيز على حقل الإدخال",
@@ -193,6 +197,11 @@ export const dict = {
"model.input.video": "فيديو",
"model.input.pdf": "pdf",
"model.tooltip.allows": "يسمح: {{inputs}}",
"model.tooltip.context.label": "السياق",
"model.tooltip.inputs": "المدخلات",
"model.tooltip.model": "النموذج",
"model.tooltip.provider": "الموفر",
"model.tooltip.reasoning": "الاستنتاج",
"model.tooltip.reasoning.allowed": "يسمح بالاستنتاج",
"model.tooltip.reasoning.none": "بدون استنتاج",
"model.tooltip.context": "حد السياق {{limit}}",
@@ -202,6 +211,7 @@ export const dict = {
"common.loading": "جارٍ التحميل",
"common.loading.ellipsis": "...",
"common.cancel": "إلغاء",
"common.clear": "مسح",
"common.connect": "اتصال",
"common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
@@ -218,6 +228,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc للخروج",
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
"prompt.example.3": "إصلاح الاختبارات المعطلة",
@@ -314,6 +326,74 @@ export const dict = {
"dialog.server.menu.delete": "حذف",
"dialog.server.current": "الخادم الحالي",
"dialog.server.status.default": "افتراضي",
"wsl.server.add": "إضافة خادم WSL",
"wsl.server.addShort": "إضافة WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "خادم WSL",
"wsl.server.retryStart": "إعادة محاولة التشغيل",
"wsl.server.updating": "جارٍ التحديث...",
"wsl.onboarding.step.distro": "اختر توزيعة",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "جارٍ التحقق من WSL...",
"wsl.onboarding.restartRequired": "يحتاج Windows إلى إعادة التشغيل لإكمال تثبيت WSL.",
"wsl.onboarding.ready": "WSL جاهز.",
"wsl.onboarding.required": "يلزم WSL للمتابعة.",
"wsl.onboarding.checkingDistros": "جارٍ التحقق من التوزيعات...",
"wsl.onboarding.installingDistro": "جارٍ تثبيت {{distro}}...",
"wsl.onboarding.checkingDistro": "جارٍ التحقق من {{distro}}...",
"wsl.onboarding.listingDistros": "جارٍ سرد التوزيعات...",
"wsl.onboarding.distroReady": "{{distro}} جاهزة.",
"wsl.onboarding.distroNotInstalled": "لم يتم تثبيت {{distro}} بعد.",
"wsl.onboarding.openDistroOnce": "افتح {{distro}} مرة واحدة لإكمال الإعداد.",
"wsl.onboarding.finishingDistro": "جارٍ إكمال إعداد {{distro}}.",
"wsl.onboarding.pickDistro": "اختر توزيعة أو ثبّت واحدة أدناه.",
"wsl.onboarding.checkingOpencode": "جارٍ التحقق من OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "جارٍ التحقق من OpenCode في {{distro}}...",
"wsl.onboarding.updatingOpencode": "جارٍ تحديث OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "جارٍ تحديث OpenCode في {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "حدّث OpenCode في {{distro}}.",
"wsl.onboarding.updateOpencode": "تحديث OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode جاهز في {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode جاهز.",
"wsl.onboarding.installOpencodeIn": "ثبّت OpenCode في {{distro}}.",
"wsl.onboarding.installOpencode": "تثبيت OpenCode",
"wsl.onboarding.chooseDistroFirst": "اختر توزيعة أولاً.",
"wsl.onboarding.loadFailed": "فشل تحميل حالة WSL.",
"wsl.onboarding.loading": "جارٍ التحميل...",
"wsl.onboarding.installedDistros": "التوزيعات المثبتة",
"wsl.onboarding.checkAgain": "تحقق مجددًا",
"wsl.onboarding.distroStatus.ready": "جاهزة",
"wsl.onboarding.distroStatus.checking": "جارٍ التحقق...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode غير مثبت",
"wsl.onboarding.distroStatus.missingTools": "bash و curl مفقودان",
"wsl.onboarding.distroStatus.unsupported": "غير مدعومة · استخدم WSL 2",
"wsl.onboarding.needAnotherDistro": "هل تحتاج إلى توزيعة أخرى؟",
"wsl.onboarding.needAnotherDistroHint": "ثبّت توزيعة Linux من كتالوج WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL غير مثبت",
"wsl.onboarding.wslNotInstalled.description":
"يلزم WSL (نظام Windows الفرعي لـ Linux) قبل أن يتمكن OpenCode من إضافة خادم WSL",
"wsl.onboarding.wslUnavailable.title": "WSL غير متاح",
"wsl.onboarding.wslUnavailable.description": "تعذر على OpenCode التحقق من WSL على هذا الجهاز.",
"wsl.onboarding.installWsl": "تثبيت WSL",
"wsl.onboarding.windowsRestartRequired": "أعد تشغيل Windows لإكمال تثبيت WSL، ثم أعد فتح OpenCode.",
"wsl.onboarding.next": "التالي",
"wsl.onboarding.refresh": "تحديث",
"wsl.onboarding.allDistrosAdded": "تمت إضافة جميع التوزيعات المثبتة بالفعل.",
"wsl.onboarding.noDistros": "لم يتم اكتشاف أي توزيعات بعد.",
"wsl.onboarding.install": "تثبيت",
"wsl.onboarding.installing": "جارٍ التثبيت...",
"wsl.onboarding.installDistro": "تثبيت توزيعة",
"wsl.onboarding.searchDistros": "البحث عن توزيعات",
"wsl.onboarding.wsl2Required": "يلزم WSL 2.",
"wsl.onboarding.toolsRequired": "تحتاج هذه التوزيعة إلى bash و curl.",
"wsl.onboarding.openTerminal": "فتح المحطة الطرفية",
"wsl.onboarding.path": "المسار: {{path}}",
"wsl.onboarding.notFound": "غير موجود",
"wsl.onboarding.version": "الإصدار: {{version}}",
"wsl.onboarding.unknown": "غير معروف",
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
"wsl.onboarding.adding": "جارٍ الإضافة...",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة",
@@ -370,6 +450,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "لغة",
@@ -406,10 +487,12 @@ export const dict = {
"toast.update.action.notYet": "ليس الآن",
"error.page.title": "حدث خطأ ما",
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.description.localServerStartup": "حدث خطأ أثناء بدء تشغيل الخادم المحلي.",
"error.page.details.label": "تفاصيل الخطأ",
"error.page.action.restart": "إعادة تشغيل",
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.exportLogs": "تصدير السجلات",
"error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}",
@@ -450,16 +533,38 @@ export const dict = {
"home.recentProjects": "المشاريع الحديثة",
"home.empty.title": "لا توجد مشاريع حديثة",
"home.empty.description": "ابدأ بفتح مشروع محلي",
"home.title": "الرئيسية",
"home.projects": "المشاريع",
"home.project.add": "إضافة مشروع",
"home.recentlyClosed": "المغلقة مؤخرًا",
"home.server.collapse": "طي مشاريع الخادم",
"home.server.expand": "توسيع مشاريع الخادم",
"home.sessions.search.placeholder": "البحث في الجلسات",
"home.sessions.search.placeholder.scoped": "البحث في جلسات {{scope}}",
"home.sessions.search.sessions": "الجلسات",
"home.sessions.search.noResults": "لم يتم العثور على جلسات لـ {{query}}",
"home.sessions.empty": "لا يوجد شيء هنا بعد",
"home.sessions.empty.description": "أنشئ جلسة للبدء",
"home.sessions.group.today": "اليوم",
"home.sessions.group.yesterday": "أمس",
"home.sessions.group.older": "الأقدم",
"session.tab.session": "جلسة",
"session.tab.review": "مراجعة",
"session.tab.context": "سياق",
"session.tab.unknown": "جلسة غير معروفة",
"session.panel.reviewAndFiles": "المراجعة والملفات",
"session.error.notFound": "لا يمكن العثور على هذه الجلسة",
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
"session.error.notFound.closeTab": "إغلاق علامة التبويب",
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.one": "تغيير",
"session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
"session.review.noChanges": "لا توجد تغييرات",
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
@@ -475,6 +580,10 @@ export const dict = {
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.question.minimize": "تصغير السؤال",
"session.question.restore": "استعادة السؤال",
"session.question.pending.one": "{{count}} سؤال معلق",
"session.question.pending.other": "الأسئلة المعلقة: {{count}}",
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
"session.followupDock.sendNow": "إرسال الآن",
@@ -487,9 +596,16 @@ export const dict = {
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
"session.revertDock.restore": "استعادة الرسالة",
"session.new.title": "ابنِ أي شيء",
"session.new.project.new": "مشروع جديد",
"session.new.project.search": "البحث عن المشاريع",
"session.new.project.add": "إضافة مشروع",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
"session.new.workspace.runIn": "تشغيل الجلسة في",
"session.new.workspace.triggerLocal": "محلي",
"session.new.workspace.local": "المستودع المحلي",
"session.new.workspace.existing": "مساحة عمل…",
"session.new.lastModified": "آخر تعديل",
"session.header.search.placeholder": "بحث {{project}}",
"session.header.searchFiles": "بحث عن الملفات",
@@ -550,6 +666,8 @@ export const dict = {
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات",
"sidebar.empty.title": "لا توجد مشاريع مفتوحة",
"sidebar.empty.description": "افتح مشروعًا للبدء",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",
@@ -559,6 +677,7 @@ export const dict = {
"settings.desktop.wsl.title": "تكامل WSL",
"settings.desktop.wsl.description": "تشغيل خادم OpenCode داخل WSL على Windows.",
"settings.general.section.appearance": "المظهر",
"settings.general.section.advanced": "متقدم",
"settings.general.section.notifications": "إشعارات النظام",
"settings.general.section.updates": "التحديثات",
"settings.general.section.sounds": "المؤثرات الصوتية",
@@ -566,6 +685,11 @@ export const dict = {
"settings.general.section.display": "شاشة العرض",
"settings.general.row.language.title": "اللغة",
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.shell.title": "Shell المحطة الطرفية",
"settings.general.row.shell.description":
"اختر shell المستخدم في المحطة الطرفية. تُستخدم واجهات shell المتوافقة أيضًا في استدعاءات أدوات الوكيل.",
"settings.general.row.shell.autoDefault": "تلقائي (افتراضي)",
"settings.general.row.shell.terminalOnly": "المحطة الطرفية فقط",
"settings.general.row.appearance.title": "المظهر",
"settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
"settings.general.row.colorScheme.title": "مخطط الألوان",
@@ -574,14 +698,29 @@ export const dict = {
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "خط الكود",
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "خط الطرفية",
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
"settings.general.row.followup.option.queue": "قائمة انتظار",
"settings.general.row.followup.option.steer": "توجيه",
"settings.general.row.showFileTree.title": "شجرة الملفات",
"settings.general.row.showFileTree.description": "إظهار لوحة شجرة الملفات في الجلسات",
"settings.general.row.showNavigation.title": "عناصر التحكم في التنقل",
"settings.general.row.showNavigation.description": "إظهار زري الرجوع والتقدم في شريط عنوان سطح المكتب",
"settings.general.row.showSearch.title": "لوحة الأوامر",
"settings.general.row.showSearch.description": "إظهار زر البحث ولوحة الأوامر في شريط العنوان",
"settings.general.row.showTerminal.title": "المحطة الطرفية",
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
"settings.general.row.showStatus.title": "حالة الخادم",
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
"settings.general.row.mobileTitlebarBottom.description":
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
"settings.general.row.showCustomAgents.title": "الوكلاء المخصصون",
"settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل",
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
@@ -590,6 +729,11 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
"settings.general.row.newLayoutDesigns.description":
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":
@@ -602,6 +746,8 @@ export const dict = {
"settings.updates.row.check.description": "التحقق يدويًا من التحديثات وتثبيتها إذا كانت متاحة",
"settings.updates.action.checkNow": "تحقق الآن",
"settings.updates.action.checking": "جارٍ التحقق...",
"settings.updates.action.downloading": "جارٍ التنزيل...",
"settings.updates.action.installing": "جارٍ التثبيت...",
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
"sound.option.none": "بلا",
+152 -3
View File
@@ -21,6 +21,9 @@ export const dict = {
"theme.scheme.dark": "Escuro",
"command.sidebar.toggle": "Alternar barra lateral",
"command.project.open": "Abrir projeto",
"command.project.previous": "Projeto anterior",
"command.project.next": "Próximo projeto",
"command.project.index": "Alternar para o projeto {{index}}",
"command.provider.connect": "Conectar provedor",
"command.server.switch": "Trocar servidor",
"command.settings.open": "Abrir configurações",
@@ -39,6 +42,7 @@ export const dict = {
"command.session.new": "Nova sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
"command.context.addSelection": "Adicionar seleção ao contexto",
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
"command.input.focus": "Focar entrada",
@@ -193,6 +197,11 @@ export const dict = {
"model.input.video": "vídeo",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Permite: {{inputs}}",
"model.tooltip.context.label": "Contexto",
"model.tooltip.inputs": "Entradas",
"model.tooltip.model": "Modelo",
"model.tooltip.provider": "Provedor",
"model.tooltip.reasoning": "Raciocínio",
"model.tooltip.reasoning.allowed": "Permite raciocínio",
"model.tooltip.reasoning.none": "Sem raciocínio",
"model.tooltip.context": "Limite de contexto {{limit}}",
@@ -202,6 +211,7 @@ export const dict = {
"common.loading": "Carregando",
"common.loading.ellipsis": "...",
"common.cancel": "Cancelar",
"common.clear": "Limpar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
"common.continue": "Enviar",
@@ -218,6 +228,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para sair",
"session.child.promptDisabled": "Não é possível enviar prompts em sessões de subagentes.",
"session.child.backToParent": "Voltar à sessão principal.",
"prompt.example.1": "Corrigir um TODO no código",
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
"prompt.example.3": "Corrigir testes quebrados",
@@ -314,6 +326,75 @@ export const dict = {
"dialog.server.menu.delete": "Excluir",
"dialog.server.current": "Servidor atual",
"dialog.server.status.default": "Padrão",
"wsl.server.add": "Adicionar servidor WSL",
"wsl.server.addShort": "Adicionar WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "Servidor WSL",
"wsl.server.retryStart": "Tentar iniciar novamente",
"wsl.server.updating": "Atualizando...",
"wsl.onboarding.step.distro": "Escolher distribuição",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Verificando o WSL...",
"wsl.onboarding.restartRequired": "O Windows precisa ser reiniciado para concluir a instalação do WSL.",
"wsl.onboarding.ready": "O WSL está pronto.",
"wsl.onboarding.required": "O WSL é necessário para continuar.",
"wsl.onboarding.checkingDistros": "Verificando distribuições...",
"wsl.onboarding.installingDistro": "Instalando {{distro}}...",
"wsl.onboarding.checkingDistro": "Verificando {{distro}}...",
"wsl.onboarding.listingDistros": "Listando distribuições...",
"wsl.onboarding.distroReady": "{{distro}} está pronta.",
"wsl.onboarding.distroNotInstalled": "{{distro}} ainda não está instalada.",
"wsl.onboarding.openDistroOnce": "Abra {{distro}} uma vez para concluir a configuração.",
"wsl.onboarding.finishingDistro": "Concluindo a configuração de {{distro}}.",
"wsl.onboarding.pickDistro": "Escolha uma distribuição ou instale uma abaixo.",
"wsl.onboarding.checkingOpencode": "Verificando o OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Verificando o OpenCode em {{distro}}...",
"wsl.onboarding.updatingOpencode": "Atualizando o OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Atualizando o OpenCode em {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Atualize o OpenCode em {{distro}}.",
"wsl.onboarding.updateOpencode": "Atualizar o OpenCode",
"wsl.onboarding.opencodeReadyIn": "O OpenCode está pronto em {{distro}}.",
"wsl.onboarding.opencodeReady": "O OpenCode está pronto.",
"wsl.onboarding.installOpencodeIn": "Instale o OpenCode em {{distro}}.",
"wsl.onboarding.installOpencode": "Instalar o OpenCode",
"wsl.onboarding.chooseDistroFirst": "Escolha primeiro uma distribuição.",
"wsl.onboarding.loadFailed": "Falha ao carregar o estado do WSL.",
"wsl.onboarding.loading": "Carregando...",
"wsl.onboarding.installedDistros": "Distribuições instaladas",
"wsl.onboarding.checkAgain": "Verificar novamente",
"wsl.onboarding.distroStatus.ready": "Pronta",
"wsl.onboarding.distroStatus.checking": "Verificando...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode não instalado",
"wsl.onboarding.distroStatus.missingTools": "bash e curl ausentes",
"wsl.onboarding.distroStatus.unsupported": "Sem suporte · Use o WSL 2",
"wsl.onboarding.needAnotherDistro": "Precisa de outra distribuição?",
"wsl.onboarding.needAnotherDistroHint": "Instale uma distribuição Linux pelo catálogo do WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL não instalado",
"wsl.onboarding.wslNotInstalled.description":
"O WSL (Windows Subsystem for Linux) é necessário para que o OpenCode possa adicionar um servidor WSL",
"wsl.onboarding.wslUnavailable.title": "WSL indisponível",
"wsl.onboarding.wslUnavailable.description": "O OpenCode não conseguiu verificar o WSL neste computador.",
"wsl.onboarding.installWsl": "Instalar o WSL",
"wsl.onboarding.windowsRestartRequired":
"Reinicie o Windows para concluir a instalação do WSL e abra o OpenCode novamente.",
"wsl.onboarding.next": "Próximo",
"wsl.onboarding.refresh": "Atualizar",
"wsl.onboarding.allDistrosAdded": "Todas as distribuições instaladas já foram adicionadas.",
"wsl.onboarding.noDistros": "Nenhuma distribuição detectada ainda.",
"wsl.onboarding.install": "Instalar",
"wsl.onboarding.installing": "Instalando...",
"wsl.onboarding.installDistro": "Instalar distribuição",
"wsl.onboarding.searchDistros": "Buscar distribuições",
"wsl.onboarding.wsl2Required": "O WSL 2 é necessário.",
"wsl.onboarding.toolsRequired": "Esta distribuição precisa do bash e do curl.",
"wsl.onboarding.openTerminal": "Abrir terminal",
"wsl.onboarding.path": "Caminho: {{path}}",
"wsl.onboarding.notFound": "não encontrado",
"wsl.onboarding.version": "Versão: {{version}}",
"wsl.onboarding.unknown": "desconhecido",
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.",
"wsl.onboarding.adding": "Adicionando...",
"dialog.project.edit.title": "Editar projeto",
"dialog.project.edit.name": "Nome",
"dialog.project.edit.icon": "Ícone",
@@ -371,6 +452,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Idioma",
@@ -407,10 +489,12 @@ export const dict = {
"toast.update.action.notYet": "Agora não",
"error.page.title": "Algo deu errado",
"error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.",
"error.page.details.label": "Detalhes do Erro",
"error.page.action.restart": "Reiniciar",
"error.page.action.report": "Reportar erro",
"error.page.action.reported": "Erro reportado",
"error.page.action.exportLogs": "Exportar logs",
"error.page.action.checking": "Verificando...",
"error.page.action.checkUpdates": "Verificar atualizações",
"error.page.action.updateTo": "Atualizar para {{version}}",
@@ -453,10 +537,30 @@ export const dict = {
"home.recentProjects": "Projetos recentes",
"home.empty.title": "Nenhum projeto recente",
"home.empty.description": "Comece abrindo um projeto local",
"home.title": "Início",
"home.projects": "Projetos",
"home.project.add": "Adicionar projeto",
"home.recentlyClosed": "Projetos fechados recentemente",
"home.server.collapse": "Recolher projetos do servidor",
"home.server.expand": "Expandir projetos do servidor",
"home.sessions.search.placeholder": "Buscar sessões",
"home.sessions.search.placeholder.scoped": "Buscar sessões em {{scope}}",
"home.sessions.search.sessions": "Sessões",
"home.sessions.search.noResults": "Nenhuma sessão encontrada para {{query}}",
"home.sessions.empty": "Nada por aqui ainda",
"home.sessions.empty.description": "Crie uma sessão para começar",
"home.sessions.group.today": "Hoje",
"home.sessions.group.yesterday": "Ontem",
"home.sessions.group.older": "Mais antigas",
"session.tab.session": "Sessão",
"session.tab.review": "Revisão",
"session.tab.context": "Contexto",
"session.tab.unknown": "Sessão desconhecida",
"session.panel.reviewAndFiles": "Revisão e arquivos",
"session.error.notFound": "Esta sessão não foi encontrada",
"session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.",
"session.error.notFound.closeTab": "Fechar aba",
"session.error.serverConnection": "Não é possível conectar a este servidor",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
"session.review.change.one": "Alteração",
"session.review.change.other": "Alterações",
@@ -466,6 +570,8 @@ export const dict = {
"session.review.noSnapshot":
"O rastreamento de snapshot está desabilitado na configuração, então as alterações da sessão estão indisponíveis",
"session.review.noChanges": "Sem alterações",
"session.review.noUncommittedChanges": "Ainda não há alterações sem commit",
"session.review.noBranchChanges": "Ainda não há alterações na branch",
"session.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos",
"session.files.empty": "Nenhum arquivo",
@@ -479,6 +585,10 @@ export const dict = {
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.question.minimize": "Minimizar pergunta",
"session.question.restore": "Restaurar pergunta",
"session.question.pending.one": "{{count}} pergunta pendente",
"session.question.pending.other": "{{count}} perguntas pendentes",
"session.followupDock.summary.one": "{{count}} mensagem na fila",
"session.followupDock.summary.other": "{{count}} mensagens na fila",
"session.followupDock.sendNow": "Enviar agora",
@@ -491,9 +601,16 @@ export const dict = {
"session.revertDock.expand": "Expandir mensagens revertidas",
"session.revertDock.restore": "Restaurar mensagem",
"session.new.title": "Crie qualquer coisa",
"session.new.project.new": "Novo projeto",
"session.new.project.search": "Buscar projetos",
"session.new.project.add": "Adicionar projeto",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",
"session.new.workspace.runIn": "Executar sessão em",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Repositório local",
"session.new.workspace.existing": "Espaço de trabalho…",
"session.new.lastModified": "Última modificação",
"session.header.search.placeholder": "Buscar {{project}}",
"session.header.searchFiles": "Buscar arquivos",
@@ -557,15 +674,18 @@ export const dict = {
"sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões",
"sidebar.project.clearNotifications": "Limpar notificações",
"sidebar.empty.title": "Nenhum projeto aberto",
"sidebar.empty.description": "Abra um projeto para começar",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Servidor",
"settings.tab.general": "Geral",
"settings.tab.shortcuts": "Atalhos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.title": "Integração com WSL",
"settings.desktop.wsl.description": "Executar o servidor OpenCode dentro do WSL no Windows.",
"settings.general.section.appearance": "Aparência",
"settings.general.section.advanced": "Avançado",
"settings.general.section.notifications": "Notificações do sistema",
"settings.general.section.updates": "Atualizações",
"settings.general.section.sounds": "Efeitos sonoros",
@@ -573,6 +693,11 @@ export const dict = {
"settings.general.section.display": "Tela",
"settings.general.row.language.title": "Idioma",
"settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
"settings.general.row.shell.title": "Shell do terminal",
"settings.general.row.shell.description":
"Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.",
"settings.general.row.shell.autoDefault": "Automático (Padrão)",
"settings.general.row.shell.terminalOnly": "somente no terminal",
"settings.general.row.appearance.title": "Aparência",
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
"settings.general.row.colorScheme.title": "Esquema de cores",
@@ -581,8 +706,8 @@ export const dict = {
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte de código",
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Fonte do terminal",
"settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal",
"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",
@@ -590,6 +715,22 @@ export const dict = {
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
"settings.general.row.followup.option.queue": "Fila",
"settings.general.row.followup.option.steer": "Orientar",
"settings.general.row.showFileTree.title": "Árvore de arquivos",
"settings.general.row.showFileTree.description": "Mostrar o painel da árvore de arquivos nas sessões",
"settings.general.row.showNavigation.title": "Controles de navegação",
"settings.general.row.showNavigation.description":
"Mostrar os botões de voltar e avançar na barra de título do desktop",
"settings.general.row.showSearch.title": "Paleta de comandos",
"settings.general.row.showSearch.description": "Mostrar o botão de busca e paleta de comandos na barra de título",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop",
"settings.general.row.showStatus.title": "Status do servidor",
"settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título",
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
"settings.general.row.mobileTitlebarBottom.description":
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
"settings.general.row.showCustomAgents.title": "Agentes personalizados",
"settings.general.row.showCustomAgents.description": "Mostrar o seletor de agentes na área de composição",
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
@@ -598,6 +739,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.newLayoutDesigns.title": "Novo layout e design",
"settings.general.row.newLayoutDesigns.description":
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados",
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
"settings.general.row.pinchZoom.description":
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip":
@@ -610,6 +757,8 @@ export const dict = {
"settings.updates.row.check.description": "Verificar atualizações manualmente e instalar se houver",
"settings.updates.action.checkNow": "Verificar agora",
"settings.updates.action.checking": "Verificando...",
"settings.updates.action.downloading": "Baixando...",
"settings.updates.action.installing": "Instalando...",
"settings.updates.toast.latest.title": "Você está atualizado",
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
"sound.option.none": "Nenhum",
+151 -2
View File
@@ -23,6 +23,9 @@ export const dict = {
"command.sidebar.toggle": "Prikaži/sakrij bočnu traku",
"command.project.open": "Otvori projekat",
"command.project.previous": "Prethodni projekat",
"command.project.next": "Sljedeći projekat",
"command.project.index": "Prebaci na projekat {{index}}",
"command.provider.connect": "Poveži provajdera",
"command.server.switch": "Promijeni server",
"command.settings.open": "Otvori postavke",
@@ -45,6 +48,7 @@ export const dict = {
"command.session.new": "Nova sesija",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
"command.context.addSelection": "Dodaj odabir u kontekst",
"command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke",
"command.input.focus": "Fokusiraj polje za unos",
@@ -209,6 +213,11 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Podržava: {{inputs}}",
"model.tooltip.context.label": "Kontekst",
"model.tooltip.inputs": "Ulazi",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Provajder",
"model.tooltip.reasoning": "Rasuđivanje",
"model.tooltip.reasoning.allowed": "Podržava rasuđivanje",
"model.tooltip.reasoning.none": "Bez rasuđivanja",
"model.tooltip.context": "Limit konteksta {{limit}}",
@@ -219,6 +228,7 @@ export const dict = {
"common.loading": "Učitavanje",
"common.loading.ellipsis": "...",
"common.cancel": "Otkaži",
"common.clear": "Očisti",
"common.connect": "Poveži",
"common.disconnect": "Prekini vezu",
"common.continue": "Pošalji",
@@ -236,6 +246,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc za izlaz",
"session.child.promptDisabled": "Sesijama podagenta nije moguće slati upite.",
"session.child.backToParent": "Nazad na glavnu sesiju.",
"prompt.example.1": "Popravi TODO u bazi koda",
"prompt.example.2": "Koji je tehnološki stack ovog projekta?",
@@ -342,6 +354,75 @@ export const dict = {
"dialog.server.menu.delete": "Izbriši",
"dialog.server.current": "Trenutni server",
"dialog.server.status.default": "Podrazumijevano",
"wsl.server.add": "Dodaj WSL server",
"wsl.server.addShort": "Dodaj WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL server",
"wsl.server.retryStart": "Ponovi pokretanje",
"wsl.server.updating": "Ažuriranje...",
"wsl.onboarding.step.distro": "Odaberi distribuciju",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Provjera WSL-a...",
"wsl.onboarding.restartRequired": "Potrebno je ponovo pokrenuti Windows da bi se završila instalacija WSL-a.",
"wsl.onboarding.ready": "WSL je spreman.",
"wsl.onboarding.required": "WSL je potreban za nastavak.",
"wsl.onboarding.checkingDistros": "Provjera distribucija...",
"wsl.onboarding.installingDistro": "Instaliranje {{distro}}...",
"wsl.onboarding.checkingDistro": "Provjera {{distro}}...",
"wsl.onboarding.listingDistros": "Učitavanje liste distribucija...",
"wsl.onboarding.distroReady": "Distribucija {{distro}} je spremna.",
"wsl.onboarding.distroNotInstalled": "Distribucija {{distro}} još nije instalirana.",
"wsl.onboarding.openDistroOnce": "Otvori {{distro}} jednom da završiš postavljanje.",
"wsl.onboarding.finishingDistro": "Završavanje postavljanja za {{distro}}.",
"wsl.onboarding.pickDistro": "Odaberi distribuciju ili instaliraj jednu ispod.",
"wsl.onboarding.checkingOpencode": "Provjera OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Provjera OpenCode u distribuciji {{distro}}...",
"wsl.onboarding.updatingOpencode": "Ažuriranje OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Ažuriranje OpenCode u distribuciji {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Ažuriraj OpenCode u distribuciji {{distro}}.",
"wsl.onboarding.updateOpencode": "Ažuriraj OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode je spreman u distribuciji {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode je spreman.",
"wsl.onboarding.installOpencodeIn": "Instaliraj OpenCode u distribuciji {{distro}}.",
"wsl.onboarding.installOpencode": "Instaliraj OpenCode",
"wsl.onboarding.chooseDistroFirst": "Prvo odaberi distribuciju.",
"wsl.onboarding.loadFailed": "Nije uspjelo učitavanje WSL stanja.",
"wsl.onboarding.loading": "Učitavanje...",
"wsl.onboarding.installedDistros": "Instalirane distribucije",
"wsl.onboarding.checkAgain": "Provjeri ponovo",
"wsl.onboarding.distroStatus.ready": "Spremno",
"wsl.onboarding.distroStatus.checking": "Provjera...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nije instaliran",
"wsl.onboarding.distroStatus.missingTools": "Nedostaju bash i curl",
"wsl.onboarding.distroStatus.unsupported": "Nije podržano · Koristi WSL 2",
"wsl.onboarding.needAnotherDistro": "Treba ti druga distribucija?",
"wsl.onboarding.needAnotherDistroHint": "Instaliraj Linux distribuciju iz WSL kataloga",
"wsl.onboarding.wslNotInstalled.title": "WSL nije instaliran",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) je potreban da bi OpenCode mogao dodati WSL server",
"wsl.onboarding.wslUnavailable.title": "WSL nije dostupan",
"wsl.onboarding.wslUnavailable.description": "OpenCode nije mogao provjeriti WSL na ovom uređaju.",
"wsl.onboarding.installWsl": "Instaliraj WSL",
"wsl.onboarding.windowsRestartRequired":
"Ponovo pokreni Windows da završiš instalaciju WSL-a, zatim ponovo otvori OpenCode.",
"wsl.onboarding.next": "Dalje",
"wsl.onboarding.refresh": "Osvježi",
"wsl.onboarding.allDistrosAdded": "Sve instalirane distribucije su već dodane.",
"wsl.onboarding.noDistros": "Još nema otkrivenih distribucija.",
"wsl.onboarding.install": "Instaliraj",
"wsl.onboarding.installing": "Instaliranje...",
"wsl.onboarding.installDistro": "Instaliraj distribuciju",
"wsl.onboarding.searchDistros": "Pretraži distribucije",
"wsl.onboarding.wsl2Required": "Potreban je WSL 2.",
"wsl.onboarding.toolsRequired": "Ovoj distribuciji su potrebni bash i curl.",
"wsl.onboarding.openTerminal": "Otvori terminal",
"wsl.onboarding.path": "Putanja: {{path}}",
"wsl.onboarding.notFound": "nije pronađeno",
"wsl.onboarding.version": "Verzija: {{version}}",
"wsl.onboarding.unknown": "nepoznato",
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.",
"wsl.onboarding.adding": "Dodavanje...",
"dialog.project.edit.title": "Uredi projekat",
"dialog.project.edit.name": "Naziv",
@@ -405,6 +486,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -453,10 +535,12 @@ export const dict = {
"error.page.title": "Nešto je pošlo po zlu",
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
"error.page.description.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.",
"error.page.details.label": "Detalji greške",
"error.page.action.restart": "Restartuj",
"error.page.action.report": "Prijavi grešku",
"error.page.action.reported": "Greška prijavljena",
"error.page.action.exportLogs": "Izvezi logove",
"error.page.action.checking": "Provjera...",
"error.page.action.checkUpdates": "Provjeri ažuriranja",
"error.page.action.updateTo": "Ažuriraj na {{version}}",
@@ -504,11 +588,31 @@ export const dict = {
"home.recentProjects": "Nedavni projekti",
"home.empty.title": "Nema nedavnih projekata",
"home.empty.description": "Kreni tako što ćeš otvoriti lokalni projekat",
"home.title": "Početna",
"home.projects": "Projekti",
"home.project.add": "Dodaj projekat",
"home.recentlyClosed": "Nedavno zatvoreni projekti",
"home.server.collapse": "Sažmi projekte servera",
"home.server.expand": "Proširi projekte servera",
"home.sessions.search.placeholder": "Pretraži sesije",
"home.sessions.search.placeholder.scoped": "Pretraži sesije u {{scope}}",
"home.sessions.search.sessions": "Sesije",
"home.sessions.search.noResults": "Nema pronađenih sesija za {{query}}",
"home.sessions.empty": "Ovdje još nema ničega",
"home.sessions.empty.description": "Kreiraj sesiju za početak",
"home.sessions.group.today": "Danas",
"home.sessions.group.yesterday": "Jučer",
"home.sessions.group.older": "Starije",
"session.tab.session": "Sesija",
"session.tab.review": "Pregled",
"session.tab.context": "Kontekst",
"session.tab.unknown": "Nepoznata sesija",
"session.panel.reviewAndFiles": "Pregled i datoteke",
"session.error.notFound": "Ova sesija nije pronađena",
"session.error.notFound.description": "Ova kartica upućuje na sesiju koja više ne postoji na ovom serveru.",
"session.error.notFound.closeTab": "Zatvori karticu",
"session.error.serverConnection": "Nije moguće povezati se s ovim serverom",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
"session.review.change.one": "Izmjena",
"session.review.change.other": "Izmjene",
@@ -518,6 +622,8 @@ export const dict = {
"session.review.noSnapshot":
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
"session.review.noChanges": "Nema izmjena",
"session.review.noUncommittedChanges": "Još nema nepotvrđenih promjena",
"session.review.noBranchChanges": "Još nema promjena na grani",
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
"session.files.all": "Sve datoteke",
@@ -534,6 +640,10 @@ export const dict = {
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.question.minimize": "Minimiziraj pitanje",
"session.question.restore": "Vrati pitanje",
"session.question.pending.one": "{{count}} pitanje na čekanju",
"session.question.pending.other": "{{count}} pitanja na čekanju",
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
"session.followupDock.sendNow": "Pošalji sada",
@@ -547,9 +657,16 @@ export const dict = {
"session.revertDock.restore": "Vrati poruku",
"session.new.title": "Napravi bilo šta",
"session.new.project.new": "Novi projekat",
"session.new.project.search": "Pretraži projekte",
"session.new.project.add": "Dodaj projekat",
"session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
"session.new.worktree.create": "Kreiraj novi worktree",
"session.new.workspace.runIn": "Pokreni sesiju u",
"session.new.workspace.triggerLocal": "Lokalno",
"session.new.workspace.local": "Lokalni repozitorij",
"session.new.workspace.existing": "Radni prostor…",
"session.new.lastModified": "Posljednja izmjena",
"session.header.search.placeholder": "Pretraži {{project}}",
@@ -618,6 +735,8 @@ export const dict = {
"sidebar.project.recentSessions": "Nedavne sesije",
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
"sidebar.project.clearNotifications": "Očisti obavijesti",
"sidebar.empty.title": "Nema otvorenih projekata",
"sidebar.empty.description": "Otvori projekat za početak",
"app.name.desktop": "OpenCode Desktop",
@@ -630,6 +749,7 @@ export const dict = {
"settings.desktop.wsl.description": "Pokreni OpenCode server unutar WSL-a na Windowsu.",
"settings.general.section.appearance": "Izgled",
"settings.general.section.advanced": "Napredno",
"settings.general.section.notifications": "Sistemske obavijesti",
"settings.general.section.updates": "Ažuriranja",
"settings.general.section.sounds": "Zvučni efekti",
@@ -638,6 +758,11 @@ export const dict = {
"settings.general.row.language.title": "Jezik",
"settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u",
"settings.general.row.shell.title": "Shell terminala",
"settings.general.row.shell.description":
"Odaberi shell koji se koristi za terminal. Kompatibilni shellovi koriste se i za pozive alata agenta.",
"settings.general.row.shell.autoDefault": "Automatski (podrazumijevano)",
"settings.general.row.shell.terminalOnly": "samo terminal",
"settings.general.row.appearance.title": "Izgled",
"settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju",
"settings.general.row.colorScheme.title": "Šema boja",
@@ -646,14 +771,30 @@ export const dict = {
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
"settings.general.row.font.title": "Font za kod",
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Font terminala",
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
"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",
"settings.general.row.followup.option.steer": "Usmjeri",
"settings.general.row.showFileTree.title": "Stablo datoteka",
"settings.general.row.showFileTree.description": "Prikaži stablo datoteka u sesijama",
"settings.general.row.showNavigation.title": "Kontrole navigacije",
"settings.general.row.showNavigation.description":
"Prikaži dugmad za nazad i naprijed u naslovnoj traci desktop aplikacije",
"settings.general.row.showSearch.title": "Paleta komandi",
"settings.general.row.showSearch.description": "Prikaži dugme za pretragu i paletu komandi u naslovnoj traci",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije",
"settings.general.row.showStatus.title": "Status servera",
"settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci",
"settings.general.row.mobileTitlebarBottom.title": "Donja navigacija",
"settings.general.row.mobileTitlebarBottom.description":
"Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima",
"settings.general.row.showCustomAgents.title": "Prilagođeni agenti",
"settings.general.row.showCustomAgents.description": "Prikaži izbor agenta u uređivaču poruke",
"settings.general.row.reasoningSummaries.title": "Prikaži sažetke rasuđivanja",
"settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci",
@@ -663,6 +804,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn",
"settings.general.row.newLayoutDesigns.description":
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije",
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
"settings.general.row.pinchZoom.description":
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
"settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip":
@@ -677,6 +824,8 @@ export const dict = {
"settings.updates.row.check.description": "Ručno provjeri ažuriranja i instaliraj ako su dostupna",
"settings.updates.action.checkNow": "Provjeri sada",
"settings.updates.action.checking": "Provjera...",
"settings.updates.action.downloading": "Preuzimanje...",
"settings.updates.action.installing": "Instaliranje...",
"settings.updates.toast.latest.title": "Sve je ažurno",
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
"sound.option.none": "Nijedan",
+149 -2
View File
@@ -23,6 +23,9 @@ export const dict = {
"command.sidebar.toggle": "Skift sidebjælke",
"command.project.open": "Åbn projekt",
"command.project.previous": "Forrige projekt",
"command.project.next": "Næste projekt",
"command.project.index": "Skift til projekt {{index}}",
"command.provider.connect": "Tilslut udbyder",
"command.server.switch": "Skift server",
"command.settings.open": "Åbn indstillinger",
@@ -45,6 +48,7 @@ export const dict = {
"command.session.new": "Ny session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
"command.context.addSelection": "Tilføj markering til kontekst",
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
"command.input.focus": "Fokuser inputfelt",
@@ -208,6 +212,11 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Tillader: {{inputs}}",
"model.tooltip.context.label": "Kontekst",
"model.tooltip.inputs": "Input",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Udbyder",
"model.tooltip.reasoning": "Tænkning",
"model.tooltip.reasoning.allowed": "Tillader tænkning",
"model.tooltip.reasoning.none": "Ingen tænkning",
"model.tooltip.context": "Kontekstgrænse {{limit}}",
@@ -217,6 +226,7 @@ export const dict = {
"common.loading": "Indlæser",
"common.loading.ellipsis": "...",
"common.cancel": "Annuller",
"common.clear": "Ryd",
"common.connect": "Forbind",
"common.disconnect": "Frakobl",
"common.continue": "Indsend",
@@ -234,6 +244,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc for at afslutte",
"session.child.promptDisabled": "Der kan ikke sendes prompts til underagentsessioner.",
"session.child.backToParent": "Tilbage til hovedsessionen.",
"prompt.example.1": "Ret en TODO i koden",
"prompt.example.2": "Hvad er teknologistakken for dette projekt?",
@@ -340,6 +352,75 @@ export const dict = {
"dialog.server.menu.delete": "Slet",
"dialog.server.current": "Nuværende server",
"dialog.server.status.default": "Standard",
"wsl.server.add": "Tilføj WSL-server",
"wsl.server.addShort": "Tilføj WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL-server",
"wsl.server.retryStart": "Prøv at starte igen",
"wsl.server.updating": "Opdaterer...",
"wsl.onboarding.step.distro": "Vælg distribution",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Tjekker WSL...",
"wsl.onboarding.restartRequired": "Windows skal genstartes for at fuldføre installationen af WSL.",
"wsl.onboarding.ready": "WSL er klar.",
"wsl.onboarding.required": "WSL er påkrævet for at fortsætte.",
"wsl.onboarding.checkingDistros": "Tjekker distributioner...",
"wsl.onboarding.installingDistro": "Installerer {{distro}}...",
"wsl.onboarding.checkingDistro": "Tjekker {{distro}}...",
"wsl.onboarding.listingDistros": "Henter liste over distributioner...",
"wsl.onboarding.distroReady": "{{distro}} er klar.",
"wsl.onboarding.distroNotInstalled": "{{distro}} er ikke installeret endnu.",
"wsl.onboarding.openDistroOnce": "Åbn {{distro}} én gang for at fuldføre opsætningen.",
"wsl.onboarding.finishingDistro": "Fuldfører opsætningen af {{distro}}.",
"wsl.onboarding.pickDistro": "Vælg en distribution, eller installer en nedenfor.",
"wsl.onboarding.checkingOpencode": "Tjekker OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Tjekker OpenCode i {{distro}}...",
"wsl.onboarding.updatingOpencode": "Opdaterer OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Opdaterer OpenCode i {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Opdater OpenCode i {{distro}}.",
"wsl.onboarding.updateOpencode": "Opdater OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode er klar i {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode er klar.",
"wsl.onboarding.installOpencodeIn": "Installer OpenCode i {{distro}}.",
"wsl.onboarding.installOpencode": "Installer OpenCode",
"wsl.onboarding.chooseDistroFirst": "Vælg først en distribution.",
"wsl.onboarding.loadFailed": "Kunne ikke indlæse WSL-status.",
"wsl.onboarding.loading": "Indlæser...",
"wsl.onboarding.installedDistros": "Installerede distributioner",
"wsl.onboarding.checkAgain": "Tjek igen",
"wsl.onboarding.distroStatus.ready": "Klar",
"wsl.onboarding.distroStatus.checking": "Tjekker...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode er ikke installeret",
"wsl.onboarding.distroStatus.missingTools": "Mangler bash, curl",
"wsl.onboarding.distroStatus.unsupported": "Ikke understøttet · Brug WSL 2",
"wsl.onboarding.needAnotherDistro": "Har du brug for en anden distribution?",
"wsl.onboarding.needAnotherDistroHint": "Installer en Linux-distribution fra WSL-kataloget",
"wsl.onboarding.wslNotInstalled.title": "WSL er ikke installeret",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) er påkrævet, før OpenCode kan tilføje en WSL-server",
"wsl.onboarding.wslUnavailable.title": "WSL er ikke tilgængelig",
"wsl.onboarding.wslUnavailable.description": "OpenCode kunne ikke bekræfte WSL på denne maskine.",
"wsl.onboarding.installWsl": "Installer WSL",
"wsl.onboarding.windowsRestartRequired":
"Genstart Windows for at fuldføre installationen af WSL, og åbn derefter OpenCode igen.",
"wsl.onboarding.next": "Næste",
"wsl.onboarding.refresh": "Opdater",
"wsl.onboarding.allDistrosAdded": "Alle installerede distributioner er allerede tilføjet.",
"wsl.onboarding.noDistros": "Ingen distributioner fundet endnu.",
"wsl.onboarding.install": "Installer",
"wsl.onboarding.installing": "Installerer...",
"wsl.onboarding.installDistro": "Installer distribution",
"wsl.onboarding.searchDistros": "Søg efter distributioner",
"wsl.onboarding.wsl2Required": "WSL 2 er påkrævet.",
"wsl.onboarding.toolsRequired": "Denne distribution kræver bash og curl.",
"wsl.onboarding.openTerminal": "Åbn terminal",
"wsl.onboarding.path": "Sti: {{path}}",
"wsl.onboarding.notFound": "ikke fundet",
"wsl.onboarding.version": "Version: {{version}}",
"wsl.onboarding.unknown": "ukendt",
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "Den installerede version matcher ikke desktopappens version.",
"wsl.onboarding.adding": "Tilføjer...",
"dialog.project.edit.title": "Rediger projekt",
"dialog.project.edit.name": "Navn",
@@ -403,6 +484,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -450,10 +532,12 @@ export const dict = {
"error.page.title": "Noget gik galt",
"error.page.description": "Der opstod en fejl under indlæsning af applikationen.",
"error.page.description.localServerStartup": "Der opstod en fejl under start af den lokale server.",
"error.page.details.label": "Fejldetaljer",
"error.page.action.restart": "Genstart",
"error.page.action.report": "Rapportér fejl",
"error.page.action.reported": "Fejl rapporteret",
"error.page.action.exportLogs": "Eksportér logfiler",
"error.page.action.checking": "Tjekker...",
"error.page.action.checkUpdates": "Tjek for opdateringer",
"error.page.action.updateTo": "Opdater til {{version}}",
@@ -501,11 +585,31 @@ export const dict = {
"home.recentProjects": "Seneste projekter",
"home.empty.title": "Ingen seneste projekter",
"home.empty.description": "Kom i gang ved at åbne et lokalt projekt",
"home.title": "Hjem",
"home.projects": "Projekter",
"home.project.add": "Tilføj projekt",
"home.recentlyClosed": "Lukket for nylig",
"home.server.collapse": "Skjul serverprojekter",
"home.server.expand": "Udvid serverprojekter",
"home.sessions.search.placeholder": "Søg i sessioner",
"home.sessions.search.placeholder.scoped": "Søg i sessioner i {{scope}}",
"home.sessions.search.sessions": "Sessioner",
"home.sessions.search.noResults": "Ingen sessioner fundet for {{query}}",
"home.sessions.empty": "Intet her endnu",
"home.sessions.empty.description": "Opret en session for at komme i gang",
"home.sessions.group.today": "I dag",
"home.sessions.group.yesterday": "I går",
"home.sessions.group.older": "Ældre",
"session.tab.session": "Session",
"session.tab.review": "Gennemgang",
"session.tab.context": "Kontekst",
"session.tab.unknown": "Ukendt session",
"session.panel.reviewAndFiles": "Gennemgang og filer",
"session.error.notFound": "Denne session kan ikke findes",
"session.error.notFound.description": "Denne fane henviser til en session, der ikke længere findes på denne server.",
"session.error.notFound.closeTab": "Luk fane",
"session.error.serverConnection": "Kan ikke oprette forbindelse til denne server",
"session.review.filesChanged": "{{count}} Filer ændret",
"session.review.change.one": "Ændring",
"session.review.change.other": "Ændringer",
@@ -515,6 +619,8 @@ export const dict = {
"session.review.noSnapshot":
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
"session.review.noChanges": "Ingen ændringer",
"session.review.noUncommittedChanges": "Ingen ændringer uden commit endnu",
"session.review.noBranchChanges": "Ingen grenændringer endnu",
"session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer",
"session.files.empty": "Ingen filer",
@@ -529,6 +635,10 @@ export const dict = {
"session.todo.title": "Opgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.question.minimize": "Minimer spørgsmål",
"session.question.restore": "Gendan spørgsmål",
"session.question.pending.one": "{{count}} afventende spørgsmål",
"session.question.pending.other": "{{count}} afventende spørgsmål",
"session.followupDock.summary.one": "{{count}} besked i kø",
"session.followupDock.summary.other": "{{count}} beskeder i kø",
"session.followupDock.sendNow": "Send nu",
@@ -542,9 +652,16 @@ export const dict = {
"session.revertDock.restore": "Gendan besked",
"session.new.title": "Byg hvad som helst",
"session.new.project.new": "Nyt projekt",
"session.new.project.search": "Søg efter projekter",
"session.new.project.add": "Tilføj projekt",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
"session.new.worktree.create": "Opret nyt worktree",
"session.new.workspace.runIn": "Kør session i",
"session.new.workspace.triggerLocal": "Lokal",
"session.new.workspace.local": "Lokalt repository",
"session.new.workspace.existing": "Arbejdsområde…",
"session.new.lastModified": "Sidst ændret",
"session.header.search.placeholder": "Søg {{project}}",
@@ -614,6 +731,8 @@ export const dict = {
"sidebar.project.recentSessions": "Seneste sessioner",
"sidebar.project.viewAllSessions": "Vis alle sessioner",
"sidebar.project.clearNotifications": "Ryd notifikationer",
"sidebar.empty.title": "Ingen åbne projekter",
"sidebar.empty.description": "Åbn et projekt for at komme i gang",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
@@ -625,6 +744,7 @@ export const dict = {
"settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.",
"settings.general.section.appearance": "Udseende",
"settings.general.section.advanced": "Avanceret",
"settings.general.section.notifications": "Systemmeddelelser",
"settings.general.section.updates": "Opdateringer",
"settings.general.section.sounds": "Lydeffekter",
@@ -633,6 +753,11 @@ export const dict = {
"settings.general.row.language.title": "Sprog",
"settings.general.row.language.description": "Ændr visningssproget for OpenCode",
"settings.general.row.shell.title": "Terminalshell",
"settings.general.row.shell.description":
"Vælg den shell, der bruges i din terminal. Kompatible shells bruges også til agentens værktøjskald.",
"settings.general.row.shell.autoDefault": "Automatisk (standard)",
"settings.general.row.shell.terminalOnly": "kun terminal",
"settings.general.row.appearance.title": "Udseende",
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
"settings.general.row.colorScheme.title": "Farveskema",
@@ -641,14 +766,29 @@ export const dict = {
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
"settings.general.row.font.title": "Kode-skrifttype",
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Terminalskrifttype",
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
"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ø",
"settings.general.row.followup.option.steer": "Styr",
"settings.general.row.showFileTree.title": "Filtræ",
"settings.general.row.showFileTree.description": "Vis filtræspanelet i sessioner",
"settings.general.row.showNavigation.title": "Navigationsknapper",
"settings.general.row.showNavigation.description": "Vis tilbage- og frem-knapperne i desktopappens titellinje",
"settings.general.row.showSearch.title": "Kommandopalette",
"settings.general.row.showSearch.description": "Vis knappen til søgning og kommandopaletten i titellinjen",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje",
"settings.general.row.showStatus.title": "Serverstatus",
"settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen",
"settings.general.row.mobileTitlebarBottom.title": "Navigation nederst",
"settings.general.row.mobileTitlebarBottom.description":
"Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder",
"settings.general.row.showCustomAgents.title": "Brugerdefinerede agenter",
"settings.general.row.showCustomAgents.description": "Vis agentvælgeren i promptfeltet",
"settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter",
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
@@ -657,6 +797,11 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design",
"settings.general.row.newLayoutDesigns.description":
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen",
"settings.general.row.pinchZoom.title": "Knib for at zoome",
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
"settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip":
@@ -671,6 +816,8 @@ export const dict = {
"settings.updates.row.check.description": "Tjek manuelt for opdateringer og installer, hvis tilgængelig",
"settings.updates.action.checkNow": "Tjek nu",
"settings.updates.action.checking": "Tjekker...",
"settings.updates.action.downloading": "Downloader...",
"settings.updates.action.installing": "Installerer...",
"settings.updates.toast.latest.title": "Du er opdateret",
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
+152 -2
View File
@@ -25,6 +25,9 @@ export const dict = {
"theme.scheme.dark": "Dunkel",
"command.sidebar.toggle": "Seitenleiste umschalten",
"command.project.open": "Projekt öffnen",
"command.project.previous": "Vorheriges Projekt",
"command.project.next": "Nächstes Projekt",
"command.project.index": "Zu Projekt {{index}} wechseln",
"command.provider.connect": "Anbieter verbinden",
"command.server.switch": "Server wechseln",
"command.settings.open": "Einstellungen öffnen",
@@ -43,6 +46,7 @@ export const dict = {
"command.session.new": "Neue Sitzung",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
"command.context.addSelection": "Auswahl zum Kontext hinzufügen",
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
"command.input.focus": "Eingabefeld fokussieren",
@@ -198,6 +202,11 @@ export const dict = {
"model.input.video": "Video",
"model.input.pdf": "PDF",
"model.tooltip.allows": "Erlaubt: {{inputs}}",
"model.tooltip.context.label": "Kontext",
"model.tooltip.inputs": "Eingaben",
"model.tooltip.model": "Modell",
"model.tooltip.provider": "Anbieter",
"model.tooltip.reasoning": "Reasoning",
"model.tooltip.reasoning.allowed": "Erlaubt Reasoning",
"model.tooltip.reasoning.none": "Kein Reasoning",
"model.tooltip.context": "Kontextlimit {{limit}}",
@@ -207,6 +216,7 @@ export const dict = {
"common.loading": "Laden",
"common.loading.ellipsis": "...",
"common.cancel": "Abbrechen",
"common.clear": "Leeren",
"common.connect": "Verbinden",
"common.disconnect": "Trennen",
"common.continue": "Absenden",
@@ -223,6 +233,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc zum Verlassen",
"session.child.promptDisabled": "Unteragenten-Sitzungen können keine Prompts erhalten.",
"session.child.backToParent": "Zurück zur Hauptsitzung.",
"prompt.example.1": "Ein TODO in der Codebasis beheben",
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",
"prompt.example.3": "Fehlerhafte Tests beheben",
@@ -320,6 +332,75 @@ export const dict = {
"dialog.server.menu.delete": "Löschen",
"dialog.server.current": "Aktueller Server",
"dialog.server.status.default": "Standard",
"wsl.server.add": "WSL-Server hinzufügen",
"wsl.server.addShort": "WSL hinzufügen",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL-Server",
"wsl.server.retryStart": "Start erneut versuchen",
"wsl.server.updating": "Wird aktualisiert...",
"wsl.onboarding.step.distro": "Distribution auswählen",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "WSL wird geprüft...",
"wsl.onboarding.restartRequired": "Windows muss neu gestartet werden, um die WSL-Installation abzuschließen.",
"wsl.onboarding.ready": "WSL ist bereit.",
"wsl.onboarding.required": "WSL ist erforderlich, um fortzufahren.",
"wsl.onboarding.checkingDistros": "Distributionen werden geprüft...",
"wsl.onboarding.installingDistro": "{{distro}} wird installiert...",
"wsl.onboarding.checkingDistro": "{{distro}} wird geprüft...",
"wsl.onboarding.listingDistros": "Distributionen werden aufgelistet...",
"wsl.onboarding.distroReady": "{{distro}} ist bereit.",
"wsl.onboarding.distroNotInstalled": "{{distro}} ist noch nicht installiert.",
"wsl.onboarding.openDistroOnce": "Öffnen Sie {{distro}} einmal, um die Einrichtung abzuschließen.",
"wsl.onboarding.finishingDistro": "Einrichtung von {{distro}} wird abgeschlossen.",
"wsl.onboarding.pickDistro": "Wählen Sie eine Distribution aus oder installieren Sie unten eine.",
"wsl.onboarding.checkingOpencode": "OpenCode wird geprüft...",
"wsl.onboarding.checkingOpencodeIn": "OpenCode in {{distro}} wird geprüft...",
"wsl.onboarding.updatingOpencode": "OpenCode wird aktualisiert...",
"wsl.onboarding.updatingOpencodeIn": "OpenCode in {{distro}} wird aktualisiert...",
"wsl.onboarding.updateOpencodeIn": "Aktualisieren Sie OpenCode in {{distro}}.",
"wsl.onboarding.updateOpencode": "OpenCode aktualisieren",
"wsl.onboarding.opencodeReadyIn": "OpenCode ist in {{distro}} bereit.",
"wsl.onboarding.opencodeReady": "OpenCode ist bereit.",
"wsl.onboarding.installOpencodeIn": "Installieren Sie OpenCode in {{distro}}.",
"wsl.onboarding.installOpencode": "OpenCode installieren",
"wsl.onboarding.chooseDistroFirst": "Wählen Sie zuerst eine Distribution aus.",
"wsl.onboarding.loadFailed": "WSL-Status konnte nicht geladen werden.",
"wsl.onboarding.loading": "Wird geladen...",
"wsl.onboarding.installedDistros": "Installierte Distributionen",
"wsl.onboarding.checkAgain": "Erneut prüfen",
"wsl.onboarding.distroStatus.ready": "Bereit",
"wsl.onboarding.distroStatus.checking": "Wird geprüft...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nicht installiert",
"wsl.onboarding.distroStatus.missingTools": "Fehlend: bash, curl",
"wsl.onboarding.distroStatus.unsupported": "Nicht unterstützt · WSL 2 verwenden",
"wsl.onboarding.needAnotherDistro": "Andere Distribution benötigt?",
"wsl.onboarding.needAnotherDistroHint": "Installieren Sie eine Linux-Distribution aus dem WSL-Katalog",
"wsl.onboarding.wslNotInstalled.title": "WSL nicht installiert",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) ist erforderlich, bevor OpenCode einen WSL-Server hinzufügen kann",
"wsl.onboarding.wslUnavailable.title": "WSL nicht verfügbar",
"wsl.onboarding.wslUnavailable.description": "OpenCode konnte WSL auf diesem Computer nicht überprüfen.",
"wsl.onboarding.installWsl": "WSL installieren",
"wsl.onboarding.windowsRestartRequired":
"Starten Sie Windows neu, um die WSL-Installation abzuschließen, und öffnen Sie dann OpenCode erneut.",
"wsl.onboarding.next": "Weiter",
"wsl.onboarding.refresh": "Aktualisieren",
"wsl.onboarding.allDistrosAdded": "Alle installierten Distributionen wurden bereits hinzugefügt.",
"wsl.onboarding.noDistros": "Noch keine Distributionen erkannt.",
"wsl.onboarding.install": "Installieren",
"wsl.onboarding.installing": "Wird installiert...",
"wsl.onboarding.installDistro": "Distribution installieren",
"wsl.onboarding.searchDistros": "Distributionen durchsuchen",
"wsl.onboarding.wsl2Required": "WSL 2 ist erforderlich.",
"wsl.onboarding.toolsRequired": "Diese Distribution benötigt bash und curl.",
"wsl.onboarding.openTerminal": "Terminal öffnen",
"wsl.onboarding.path": "Pfad: {{path}}",
"wsl.onboarding.notFound": "nicht gefunden",
"wsl.onboarding.version": "Version: {{version}}",
"wsl.onboarding.unknown": "unbekannt",
"wsl.onboarding.desktopVersion": "Desktop {{version}}",
"wsl.onboarding.versionMismatch": "Die installierte Version entspricht nicht der Version der Desktop-App.",
"wsl.onboarding.adding": "Wird hinzugefügt...",
"dialog.project.edit.title": "Projekt bearbeiten",
"dialog.project.edit.name": "Name",
"dialog.project.edit.icon": "Icon",
@@ -378,6 +459,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Sprache",
@@ -414,10 +496,12 @@ export const dict = {
"toast.update.action.notYet": "Noch nicht",
"error.page.title": "Etwas ist schiefgelaufen",
"error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.",
"error.page.description.localServerStartup": "Beim Starten des lokalen Servers ist ein Fehler aufgetreten.",
"error.page.details.label": "Fehlerdetails",
"error.page.action.restart": "Neustart",
"error.page.action.report": "Fehler melden",
"error.page.action.reported": "Fehler gemeldet",
"error.page.action.exportLogs": "Protokolle exportieren",
"error.page.action.checking": "Prüfen...",
"error.page.action.checkUpdates": "Nach Updates suchen",
"error.page.action.updateTo": "Auf {{version}} aktualisieren",
@@ -461,10 +545,31 @@ export const dict = {
"home.recentProjects": "Letzte Projekte",
"home.empty.title": "Keine letzten Projekte",
"home.empty.description": "Starten Sie, indem Sie ein lokales Projekt öffnen",
"home.title": "Startseite",
"home.projects": "Projekte",
"home.project.add": "Projekt hinzufügen",
"home.recentlyClosed": "Kürzlich geschlossen",
"home.server.collapse": "Serverprojekte einklappen",
"home.server.expand": "Serverprojekte ausklappen",
"home.sessions.search.placeholder": "Sitzungen durchsuchen",
"home.sessions.search.placeholder.scoped": "Sitzungen in {{scope}} durchsuchen",
"home.sessions.search.sessions": "Sitzungen",
"home.sessions.search.noResults": "Keine Sitzungen für {{query}} gefunden",
"home.sessions.empty": "Hier ist noch nichts",
"home.sessions.empty.description": "Erstellen Sie eine Sitzung, um loszulegen",
"home.sessions.group.today": "Heute",
"home.sessions.group.yesterday": "Gestern",
"home.sessions.group.older": "Älter",
"session.tab.session": "Sitzung",
"session.tab.review": "Überprüfung",
"session.tab.context": "Kontext",
"session.tab.unknown": "Unbekannte Sitzung",
"session.panel.reviewAndFiles": "Überprüfung und Dateien",
"session.error.notFound": "Diese Sitzung wurde nicht gefunden",
"session.error.notFound.description":
"Dieser Tab verweist auf eine Sitzung, die auf diesem Server nicht mehr existiert.",
"session.error.notFound.closeTab": "Tab schließen",
"session.error.serverConnection": "Verbindung zu diesem Server nicht möglich",
"session.review.filesChanged": "{{count}} Dateien geändert",
"session.review.change.one": "Änderung",
"session.review.change.other": "Änderungen",
@@ -474,6 +579,8 @@ export const dict = {
"session.review.noSnapshot":
"Snapshot-Tracking ist in der Konfiguration deaktiviert, daher sind Sitzungsänderungen nicht verfügbar",
"session.review.noChanges": "Keine Änderungen",
"session.review.noUncommittedChanges": "Noch keine nicht committeten Änderungen",
"session.review.noBranchChanges": "Noch keine Branch-Änderungen",
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
"session.files.all": "Alle Dateien",
"session.files.empty": "Keine Dateien",
@@ -487,6 +594,10 @@ export const dict = {
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.question.minimize": "Frage minimieren",
"session.question.restore": "Frage wiederherstellen",
"session.question.pending.one": "{{count}} ausstehende Frage",
"session.question.pending.other": "{{count}} ausstehende Fragen",
"session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange",
"session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange",
"session.followupDock.sendNow": "Jetzt senden",
@@ -499,9 +610,16 @@ export const dict = {
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
"session.revertDock.restore": "Nachricht wiederherstellen",
"session.new.title": "Baue, was du willst",
"session.new.project.new": "Neues Projekt",
"session.new.project.search": "Projekte durchsuchen",
"session.new.project.add": "Projekt hinzufügen",
"session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
"session.new.worktree.create": "Neuen Worktree erstellen",
"session.new.workspace.runIn": "Sitzung ausführen in",
"session.new.workspace.triggerLocal": "Lokal",
"session.new.workspace.local": "Lokales Repository",
"session.new.workspace.existing": "Arbeitsbereich…",
"session.new.lastModified": "Zuletzt geändert",
"session.header.search.placeholder": "{{project}} durchsuchen",
"session.header.searchFiles": "Dateien suchen",
@@ -566,6 +684,8 @@ export const dict = {
"sidebar.project.recentSessions": "Letzte Sitzungen",
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
"sidebar.empty.title": "Keine Projekte geöffnet",
"sidebar.empty.description": "Öffnen Sie ein Projekt, um loszulegen",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
@@ -575,6 +695,7 @@ export const dict = {
"settings.desktop.wsl.title": "WSL-Integration",
"settings.desktop.wsl.description": "OpenCode-Server innerhalb von WSL unter Windows ausführen.",
"settings.general.section.appearance": "Erscheinungsbild",
"settings.general.section.advanced": "Erweitert",
"settings.general.section.notifications": "Systembenachrichtigungen",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Soundeffekte",
@@ -582,6 +703,11 @@ export const dict = {
"settings.general.section.display": "Anzeige",
"settings.general.row.language.title": "Sprache",
"settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern",
"settings.general.row.shell.title": "Terminal-Shell",
"settings.general.row.shell.description":
"Wählen Sie die Shell für Ihr Terminal aus. Kompatible Shells werden auch für Tool-Aufrufe von Agenten verwendet.",
"settings.general.row.shell.autoDefault": "Automatisch (Standard)",
"settings.general.row.shell.terminalOnly": "nur Terminal",
"settings.general.row.appearance.title": "Erscheinungsbild",
"settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht",
"settings.general.row.colorScheme.title": "Farbschema",
@@ -591,8 +717,8 @@ export const dict = {
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Code-Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Terminalschriftart",
"settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an",
"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",
@@ -600,6 +726,23 @@ export const dict = {
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
"settings.general.row.followup.option.queue": "Warteschlange",
"settings.general.row.followup.option.steer": "Steuern",
"settings.general.row.showFileTree.title": "Dateibaum",
"settings.general.row.showFileTree.description": "Dateibaum in Sitzungen anzeigen",
"settings.general.row.showNavigation.title": "Navigationssteuerung",
"settings.general.row.showNavigation.description":
"Schaltflächen zum Vor- und Zurücknavigieren in der Desktop-Titelleiste anzeigen",
"settings.general.row.showSearch.title": "Befehlspalette",
"settings.general.row.showSearch.description":
"Schaltfläche für Suche und Befehlspalette in der Titelleiste anzeigen",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen",
"settings.general.row.showStatus.title": "Serverstatus",
"settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen",
"settings.general.row.mobileTitlebarBottom.title": "Navigation unten",
"settings.general.row.mobileTitlebarBottom.description":
"Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren",
"settings.general.row.showCustomAgents.title": "Benutzerdefinierte Agenten",
"settings.general.row.showCustomAgents.description": "Agentenauswahl im Eingabebereich anzeigen",
"settings.general.row.reasoningSummaries.title": "Reasoning-Zusammenfassungen anzeigen",
"settings.general.row.reasoningSummaries.description":
"Zusammenfassungen des Modell-Reasonings in der Timeline anzeigen",
@@ -609,6 +752,11 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design",
"settings.general.row.newLayoutDesigns.description":
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren",
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
"settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip":
@@ -621,6 +769,8 @@ export const dict = {
"settings.updates.row.check.description": "Manuell nach Updates suchen und installieren, wenn verfügbar",
"settings.updates.action.checkNow": "Jetzt prüfen",
"settings.updates.action.checking": "Wird geprüft...",
"settings.updates.action.downloading": "Wird heruntergeladen...",
"settings.updates.action.installing": "Wird installiert...",
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
"sound.option.none": "Keine",
+6
View File
@@ -48,6 +48,7 @@ export const dict = {
"command.session.new": "New session",
"command.file.open": "Open file",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
"command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file",
"command.input.focus": "Focus input",
@@ -211,6 +212,11 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Allows: {{inputs}}",
"model.tooltip.context.label": "Context",
"model.tooltip.inputs": "Inputs",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Provider",
"model.tooltip.reasoning": "Reasoning",
"model.tooltip.reasoning.allowed": "Allows reasoning",
"model.tooltip.reasoning.none": "No reasoning",
"model.tooltip.context": "Context limit {{limit}}",
+152 -2
View File
@@ -23,6 +23,9 @@ export const dict = {
"command.sidebar.toggle": "Alternar barra lateral",
"command.project.open": "Abrir proyecto",
"command.project.previous": "Proyecto anterior",
"command.project.next": "Siguiente proyecto",
"command.project.index": "Cambiar al proyecto {{index}}",
"command.provider.connect": "Conectar proveedor",
"command.server.switch": "Cambiar servidor",
"command.settings.open": "Abrir ajustes",
@@ -45,6 +48,7 @@ export const dict = {
"command.session.new": "Nueva sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
"command.context.addSelection": "Añadir selección al contexto",
"command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual",
"command.input.focus": "Enfocar entrada",
@@ -208,6 +212,11 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Permite: {{inputs}}",
"model.tooltip.context.label": "Contexto",
"model.tooltip.inputs": "Entradas",
"model.tooltip.model": "Modelo",
"model.tooltip.provider": "Proveedor",
"model.tooltip.reasoning": "Razonamiento",
"model.tooltip.reasoning.allowed": "Permite razonamiento",
"model.tooltip.reasoning.none": "Sin razonamiento",
"model.tooltip.context": "Límite de contexto {{limit}}",
@@ -218,6 +227,7 @@ export const dict = {
"common.loading": "Cargando",
"common.loading.ellipsis": "...",
"common.cancel": "Cancelar",
"common.clear": "Limpiar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
"common.continue": "Enviar",
@@ -235,6 +245,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para salir",
"session.child.promptDisabled": "No se pueden enviar prompts a las sesiones de subagentes.",
"session.child.backToParent": "Volver a la sesión principal.",
"prompt.example.1": "Arreglar un TODO en el código",
"prompt.example.2": "¿Cuál es el stack tecnológico de este proyecto?",
@@ -341,6 +353,75 @@ export const dict = {
"dialog.server.menu.delete": "Eliminar",
"dialog.server.current": "Servidor actual",
"dialog.server.status.default": "Predeterminado",
"wsl.server.add": "Añadir servidor WSL",
"wsl.server.addShort": "Añadir WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "Servidor WSL",
"wsl.server.retryStart": "Reintentar el inicio",
"wsl.server.updating": "Actualizando...",
"wsl.onboarding.step.distro": "Elegir distribución",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Comprobando WSL...",
"wsl.onboarding.restartRequired": "Es necesario reiniciar Windows para terminar de instalar WSL.",
"wsl.onboarding.ready": "WSL está listo.",
"wsl.onboarding.required": "Se necesita WSL para continuar.",
"wsl.onboarding.checkingDistros": "Comprobando distribuciones...",
"wsl.onboarding.installingDistro": "Instalando {{distro}}...",
"wsl.onboarding.checkingDistro": "Comprobando {{distro}}...",
"wsl.onboarding.listingDistros": "Listando distribuciones...",
"wsl.onboarding.distroReady": "La distribución {{distro}} está lista.",
"wsl.onboarding.distroNotInstalled": "La distribución {{distro}} aún no está instalada.",
"wsl.onboarding.openDistroOnce": "Abre {{distro}} una vez para completar la configuración.",
"wsl.onboarding.finishingDistro": "Completando la configuración de {{distro}}.",
"wsl.onboarding.pickDistro": "Elige una distribución o instala una abajo.",
"wsl.onboarding.checkingOpencode": "Comprobando OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Comprobando OpenCode en {{distro}}...",
"wsl.onboarding.updatingOpencode": "Actualizando OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Actualizando OpenCode en {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Actualiza OpenCode en {{distro}}.",
"wsl.onboarding.updateOpencode": "Actualizar OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode está listo en {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode está listo.",
"wsl.onboarding.installOpencodeIn": "Instala OpenCode en {{distro}}.",
"wsl.onboarding.installOpencode": "Instalar OpenCode",
"wsl.onboarding.chooseDistroFirst": "Elige primero una distribución.",
"wsl.onboarding.loadFailed": "No se pudo cargar el estado de WSL.",
"wsl.onboarding.loading": "Cargando...",
"wsl.onboarding.installedDistros": "Distribuciones instaladas",
"wsl.onboarding.checkAgain": "Comprobar de nuevo",
"wsl.onboarding.distroStatus.ready": "Lista",
"wsl.onboarding.distroStatus.checking": "Comprobando...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode no está instalado",
"wsl.onboarding.distroStatus.missingTools": "Faltan bash y curl",
"wsl.onboarding.distroStatus.unsupported": "No compatible · Usa WSL 2",
"wsl.onboarding.needAnotherDistro": "¿Necesitas otra distribución?",
"wsl.onboarding.needAnotherDistroHint": "Instala una distribución de Linux desde el catálogo de WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL no está instalado",
"wsl.onboarding.wslNotInstalled.description":
"Se necesita WSL (Subsistema de Windows para Linux) para que OpenCode pueda añadir un servidor WSL",
"wsl.onboarding.wslUnavailable.title": "WSL no disponible",
"wsl.onboarding.wslUnavailable.description": "OpenCode no pudo comprobar WSL en este equipo.",
"wsl.onboarding.installWsl": "Instalar WSL",
"wsl.onboarding.windowsRestartRequired":
"Reinicia Windows para terminar de instalar WSL y, después, vuelve a abrir OpenCode.",
"wsl.onboarding.next": "Siguiente",
"wsl.onboarding.refresh": "Actualizar",
"wsl.onboarding.allDistrosAdded": "Ya se han añadido todas las distribuciones instaladas.",
"wsl.onboarding.noDistros": "Aún no se han detectado distribuciones.",
"wsl.onboarding.install": "Instalar",
"wsl.onboarding.installing": "Instalando...",
"wsl.onboarding.installDistro": "Instalar distribución",
"wsl.onboarding.searchDistros": "Buscar distribuciones",
"wsl.onboarding.wsl2Required": "Se necesita WSL 2.",
"wsl.onboarding.toolsRequired": "Esta distribución necesita bash y curl.",
"wsl.onboarding.openTerminal": "Abrir terminal",
"wsl.onboarding.path": "Ruta: {{path}}",
"wsl.onboarding.notFound": "no encontrado",
"wsl.onboarding.version": "Versión: {{version}}",
"wsl.onboarding.unknown": "desconocida",
"wsl.onboarding.desktopVersion": "escritorio {{version}}",
"wsl.onboarding.versionMismatch": "La versión instalada no coincide con la versión de la aplicación de escritorio.",
"wsl.onboarding.adding": "Añadiendo...",
"dialog.project.edit.title": "Editar proyecto",
"dialog.project.edit.name": "Nombre",
@@ -405,6 +486,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -453,10 +535,12 @@ export const dict = {
"error.page.title": "Algo salió mal",
"error.page.description": "Ocurrió un error al cargar la aplicación.",
"error.page.description.localServerStartup": "Ocurrió un error al iniciar el servidor local.",
"error.page.details.label": "Detalles del error",
"error.page.action.restart": "Reiniciar",
"error.page.action.report": "Informar error",
"error.page.action.reported": "Error informado",
"error.page.action.exportLogs": "Exportar registros",
"error.page.action.checking": "Comprobando...",
"error.page.action.checkUpdates": "Buscar actualizaciones",
"error.page.action.updateTo": "Actualizar a {{version}}",
@@ -505,11 +589,31 @@ export const dict = {
"home.recentProjects": "Proyectos recientes",
"home.empty.title": "Sin proyectos recientes",
"home.empty.description": "Empieza abriendo un proyecto local",
"home.title": "Inicio",
"home.projects": "Proyectos",
"home.project.add": "Añadir proyecto",
"home.recentlyClosed": "Proyectos cerrados recientemente",
"home.server.collapse": "Contraer proyectos del servidor",
"home.server.expand": "Expandir proyectos del servidor",
"home.sessions.search.placeholder": "Buscar sesiones",
"home.sessions.search.placeholder.scoped": "Buscar sesiones en {{scope}}",
"home.sessions.search.sessions": "Sesiones",
"home.sessions.search.noResults": "No se encontraron sesiones para {{query}}",
"home.sessions.empty": "Aún no hay nada",
"home.sessions.empty.description": "Crea una sesión para empezar",
"home.sessions.group.today": "Hoy",
"home.sessions.group.yesterday": "Ayer",
"home.sessions.group.older": "Anteriores",
"session.tab.session": "Sesión",
"session.tab.review": "Revisión",
"session.tab.context": "Contexto",
"session.tab.unknown": "Sesión desconocida",
"session.panel.reviewAndFiles": "Revisión y archivos",
"session.error.notFound": "No se encuentra esta sesión",
"session.error.notFound.description": "Esta pestaña apunta a una sesión que ya no existe en este servidor.",
"session.error.notFound.closeTab": "Cerrar pestaña",
"session.error.serverConnection": "No se puede conectar a este servidor",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
"session.review.change.one": "Cambio",
"session.review.change.other": "Cambios",
@@ -519,6 +623,8 @@ export const dict = {
"session.review.noSnapshot":
"El seguimiento de instantáneas está deshabilitado en la configuración, por lo que los cambios de sesión no están disponibles",
"session.review.noChanges": "Sin cambios",
"session.review.noUncommittedChanges": "Aún no hay cambios sin confirmar",
"session.review.noBranchChanges": "Aún no hay cambios en la rama",
"session.files.selectToOpen": "Selecciona un archivo para abrir",
"session.files.all": "Todos los archivos",
@@ -535,6 +641,10 @@ export const dict = {
"session.todo.title": "Tareas",
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.question.minimize": "Minimizar pregunta",
"session.question.restore": "Restaurar pregunta",
"session.question.pending.one": "{{count}} pregunta pendiente",
"session.question.pending.other": "{{count}} preguntas pendientes",
"session.followupDock.summary.one": "{{count}} mensaje en cola",
"session.followupDock.summary.other": "{{count}} mensajes en cola",
"session.followupDock.sendNow": "Enviar ahora",
@@ -548,9 +658,16 @@ export const dict = {
"session.revertDock.restore": "Restaurar mensaje",
"session.new.title": "Construye lo que quieras",
"session.new.project.new": "Nuevo proyecto",
"session.new.project.search": "Buscar proyectos",
"session.new.project.add": "Añadir proyecto",
"session.new.worktree.main": "Rama principal",
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
"session.new.workspace.runIn": "Ejecutar sesión en",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Repositorio local",
"session.new.workspace.existing": "Espacio de trabajo…",
"session.new.lastModified": "Última modificación",
"session.header.search.placeholder": "Buscar {{project}}",
@@ -621,6 +738,8 @@ export const dict = {
"sidebar.project.recentSessions": "Sesiones recientes",
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
"sidebar.project.clearNotifications": "Borrar notificaciones",
"sidebar.empty.title": "No hay proyectos abiertos",
"sidebar.empty.description": "Abre un proyecto para empezar",
"app.name.desktop": "OpenCode Desktop",
@@ -633,6 +752,7 @@ export const dict = {
"settings.desktop.wsl.description": "Ejecutar el servidor OpenCode dentro de WSL en Windows.",
"settings.general.section.appearance": "Apariencia",
"settings.general.section.advanced": "Avanzado",
"settings.general.section.notifications": "Notificaciones del sistema",
"settings.general.section.updates": "Actualizaciones",
"settings.general.section.sounds": "Efectos de sonido",
@@ -641,6 +761,11 @@ export const dict = {
"settings.general.row.language.title": "Idioma",
"settings.general.row.language.description": "Cambiar el idioma de visualización para OpenCode",
"settings.general.row.shell.title": "Shell del terminal",
"settings.general.row.shell.description":
"Elige el shell que se usará en el terminal. Los shells compatibles también se usarán para las llamadas a herramientas del agente.",
"settings.general.row.shell.autoDefault": "Automático (Predeterminado)",
"settings.general.row.shell.terminalOnly": "solo en el terminal",
"settings.general.row.appearance.title": "Apariencia",
"settings.general.row.appearance.description": "Personaliza cómo se ve OpenCode en tu dispositivo",
"settings.general.row.colorScheme.title": "Esquema de color",
@@ -649,8 +774,8 @@ export const dict = {
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
"settings.general.row.font.title": "Fuente de código",
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Fuente del terminal",
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal",
"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",
@@ -658,6 +783,23 @@ export const dict = {
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
"settings.general.row.followup.option.queue": "Cola",
"settings.general.row.followup.option.steer": "Dirigir",
"settings.general.row.showFileTree.title": "Árbol de archivos",
"settings.general.row.showFileTree.description": "Mostrar el panel del árbol de archivos en las sesiones",
"settings.general.row.showNavigation.title": "Controles de navegación",
"settings.general.row.showNavigation.description":
"Mostrar los botones para retroceder y avanzar en la barra de título de la aplicación de escritorio",
"settings.general.row.showSearch.title": "Paleta de comandos",
"settings.general.row.showSearch.description":
"Mostrar el botón de búsqueda y paleta de comandos en la barra de título",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título",
"settings.general.row.showStatus.title": "Estado del servidor",
"settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título",
"settings.general.row.mobileTitlebarBottom.title": "Navegación inferior",
"settings.general.row.mobileTitlebarBottom.description":
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
"settings.general.row.showCustomAgents.title": "Agentes personalizados",
"settings.general.row.showCustomAgents.description": "Mostrar el selector de agentes en el editor",
"settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento",
"settings.general.row.reasoningSummaries.description":
"Mostrar resúmenes del razonamiento del modelo en la línea de tiempo",
@@ -667,6 +809,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos",
"settings.general.row.newLayoutDesigns.description":
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones",
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
"settings.general.row.pinchZoom.description":
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip":
@@ -682,6 +830,8 @@ export const dict = {
"settings.updates.row.check.description": "Buscar actualizaciones manualmente e instalarlas si hay alguna",
"settings.updates.action.checkNow": "Buscar ahora",
"settings.updates.action.checking": "Buscando...",
"settings.updates.action.downloading": "Descargando...",
"settings.updates.action.installing": "Instalando...",
"settings.updates.toast.latest.title": "Estás al día",
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
"sound.option.none": "Ninguno",
+153 -2
View File
@@ -21,6 +21,9 @@ export const dict = {
"theme.scheme.dark": "Sombre",
"command.sidebar.toggle": "Basculer la barre latérale",
"command.project.open": "Ouvrir un projet",
"command.project.previous": "Projet précédent",
"command.project.next": "Projet suivant",
"command.project.index": "Passer au projet {{index}}",
"command.provider.connect": "Connecter un fournisseur",
"command.server.switch": "Changer de serveur",
"command.settings.open": "Ouvrir les paramètres",
@@ -39,6 +42,7 @@ export const dict = {
"command.session.new": "Nouvelle session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
"command.context.addSelection": "Ajouter la sélection au contexte",
"command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel",
"command.input.focus": "Focus sur l'entrée",
@@ -193,6 +197,11 @@ export const dict = {
"model.input.video": "vidéo",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Autorise : {{inputs}}",
"model.tooltip.context.label": "Contexte",
"model.tooltip.inputs": "Entrées",
"model.tooltip.model": "Modèle",
"model.tooltip.provider": "Fournisseur",
"model.tooltip.reasoning": "Raisonnement",
"model.tooltip.reasoning.allowed": "Autorise le raisonnement",
"model.tooltip.reasoning.none": "Sans raisonnement",
"model.tooltip.context": "Limite de contexte {{limit}}",
@@ -202,6 +211,7 @@ export const dict = {
"common.loading": "Chargement",
"common.loading.ellipsis": "...",
"common.cancel": "Annuler",
"common.clear": "Effacer",
"common.connect": "Connecter",
"common.disconnect": "Déconnecter",
"common.continue": "Soumettre",
@@ -218,6 +228,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc pour quitter",
"session.child.promptDisabled": "Les sessions de sous-agents ne peuvent pas recevoir de messages.",
"session.child.backToParent": "Revenir à la session principale.",
"prompt.example.1": "Corriger un TODO dans la base de code",
"prompt.example.2": "Quelle est la pile technique de ce projet ?",
"prompt.example.3": "Réparer les tests échoués",
@@ -315,6 +327,75 @@ export const dict = {
"dialog.server.menu.delete": "Supprimer",
"dialog.server.current": "Serveur actuel",
"dialog.server.status.default": "Défaut",
"wsl.server.add": "Ajouter un serveur WSL",
"wsl.server.addShort": "Ajouter WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "Serveur WSL",
"wsl.server.retryStart": "Réessayer de démarrer",
"wsl.server.updating": "Mise à jour...",
"wsl.onboarding.step.distro": "Choisir une distribution",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Vérification de WSL...",
"wsl.onboarding.restartRequired": "Windows doit redémarrer pour terminer l'installation de WSL.",
"wsl.onboarding.ready": "WSL est prêt.",
"wsl.onboarding.required": "WSL est requis pour continuer.",
"wsl.onboarding.checkingDistros": "Vérification des distributions...",
"wsl.onboarding.installingDistro": "Installation de {{distro}}...",
"wsl.onboarding.checkingDistro": "Vérification de {{distro}}...",
"wsl.onboarding.listingDistros": "Récupération de la liste des distributions...",
"wsl.onboarding.distroReady": "{{distro}} est prête.",
"wsl.onboarding.distroNotInstalled": "{{distro}} n'est pas encore installée.",
"wsl.onboarding.openDistroOnce": "Ouvrez {{distro}} une fois pour terminer la configuration.",
"wsl.onboarding.finishingDistro": "Finalisation de la configuration de {{distro}}.",
"wsl.onboarding.pickDistro": "Choisissez une distribution ou installez-en une ci-dessous.",
"wsl.onboarding.checkingOpencode": "Vérification d'OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Vérification d'OpenCode dans {{distro}}...",
"wsl.onboarding.updatingOpencode": "Mise à jour d'OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Mise à jour d'OpenCode dans {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Mettez à jour OpenCode dans {{distro}}.",
"wsl.onboarding.updateOpencode": "Mettre à jour OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode est prêt dans {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode est prêt.",
"wsl.onboarding.installOpencodeIn": "Installez OpenCode dans {{distro}}.",
"wsl.onboarding.installOpencode": "Installer OpenCode",
"wsl.onboarding.chooseDistroFirst": "Choisissez d'abord une distribution.",
"wsl.onboarding.loadFailed": "Impossible de charger l'état de WSL.",
"wsl.onboarding.loading": "Chargement...",
"wsl.onboarding.installedDistros": "Distributions installées",
"wsl.onboarding.checkAgain": "Vérifier à nouveau",
"wsl.onboarding.distroStatus.ready": "Prête",
"wsl.onboarding.distroStatus.checking": "Vérification...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode non installé",
"wsl.onboarding.distroStatus.missingTools": "bash et curl manquants",
"wsl.onboarding.distroStatus.unsupported": "Non prise en charge · Utilisez WSL 2",
"wsl.onboarding.needAnotherDistro": "Besoin d'une autre distribution ?",
"wsl.onboarding.needAnotherDistroHint": "Installez une distribution Linux depuis le catalogue WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL n'est pas installé",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) est requis avant qu'OpenCode puisse ajouter un serveur WSL",
"wsl.onboarding.wslUnavailable.title": "WSL indisponible",
"wsl.onboarding.wslUnavailable.description": "OpenCode n'a pas pu vérifier WSL sur cet appareil.",
"wsl.onboarding.installWsl": "Installer WSL",
"wsl.onboarding.windowsRestartRequired":
"Redémarrez Windows pour terminer l'installation de WSL, puis rouvrez OpenCode.",
"wsl.onboarding.next": "Suivant",
"wsl.onboarding.refresh": "Actualiser",
"wsl.onboarding.allDistrosAdded": "Toutes les distributions installées ont déjà été ajoutées.",
"wsl.onboarding.noDistros": "Aucune distribution détectée pour l'instant.",
"wsl.onboarding.install": "Installer",
"wsl.onboarding.installing": "Installation...",
"wsl.onboarding.installDistro": "Installer une distribution",
"wsl.onboarding.searchDistros": "Rechercher des distributions",
"wsl.onboarding.wsl2Required": "WSL 2 est requis.",
"wsl.onboarding.toolsRequired": "Cette distribution nécessite bash et curl.",
"wsl.onboarding.openTerminal": "Ouvrir le terminal",
"wsl.onboarding.path": "Chemin : {{path}}",
"wsl.onboarding.notFound": "introuvable",
"wsl.onboarding.version": "Version : {{version}}",
"wsl.onboarding.unknown": "inconnue",
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "La version installée ne correspond pas à celle de l'application desktop.",
"wsl.onboarding.adding": "Ajout...",
"dialog.project.edit.title": "Modifier le projet",
"dialog.project.edit.name": "Nom",
"dialog.project.edit.icon": "Icône",
@@ -373,6 +454,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Langue",
@@ -410,10 +492,12 @@ export const dict = {
"toast.update.action.notYet": "Pas encore",
"error.page.title": "Quelque chose s'est mal passé",
"error.page.description": "Une erreur s'est produite lors du chargement de l'application.",
"error.page.description.localServerStartup": "Une erreur s'est produite lors du démarrage du serveur local.",
"error.page.details.label": "Détails de l'erreur",
"error.page.action.restart": "Redémarrer",
"error.page.action.report": "Signaler l'erreur",
"error.page.action.reported": "Erreur signalée",
"error.page.action.exportLogs": "Exporter les journaux",
"error.page.action.checking": "Vérification...",
"error.page.action.checkUpdates": "Vérifier les mises à jour",
"error.page.action.updateTo": "Mettre à jour vers {{version}}",
@@ -458,16 +542,38 @@ export const dict = {
"home.recentProjects": "Projets récents",
"home.empty.title": "Aucun projet récent",
"home.empty.description": "Commencez par ouvrir un projet local",
"home.title": "Accueil",
"home.projects": "Projets",
"home.project.add": "Ajouter un projet",
"home.recentlyClosed": "Récemment fermés",
"home.server.collapse": "Réduire les projets du serveur",
"home.server.expand": "Développer les projets du serveur",
"home.sessions.search.placeholder": "Rechercher des sessions",
"home.sessions.search.placeholder.scoped": "Rechercher les sessions dans {{scope}}",
"home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "Aucune session trouvée pour {{query}}",
"home.sessions.empty": "Rien ici pour l'instant",
"home.sessions.empty.description": "Créez une session pour commencer",
"home.sessions.group.today": "Aujourd'hui",
"home.sessions.group.yesterday": "Hier",
"home.sessions.group.older": "Plus anciennes",
"session.tab.session": "Session",
"session.tab.review": "Revue",
"session.tab.context": "Contexte",
"session.tab.unknown": "Session inconnue",
"session.panel.reviewAndFiles": "Revue et fichiers",
"session.error.notFound": "Cette session est introuvable",
"session.error.notFound.description": "Cet onglet pointe vers une session qui n'existe plus sur ce serveur.",
"session.error.notFound.closeTab": "Fermer l'onglet",
"session.error.serverConnection": "Impossible de se connecter à ce serveur",
"session.review.filesChanged": "{{count}} fichiers modifiés",
"session.review.change.one": "Modification",
"session.review.change.other": "Modifications",
"session.review.loadingChanges": "Chargement des modifications...",
"session.review.empty": "Aucune modification dans cette session pour l'instant",
"session.review.noChanges": "Aucune modification",
"session.review.noUncommittedChanges": "Aucune modification non validée pour l'instant",
"session.review.noBranchChanges": "Aucune modification de branche pour l'instant",
"session.review.noVcs": "Aucun système de contrôle de version Git détecté, modifications non affichées",
"session.review.noSnapshot":
"Le suivi des instantanés est désactivé dans la configuration, les modifications de session sont donc indisponibles",
@@ -484,6 +590,10 @@ export const dict = {
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.question.minimize": "Réduire la question",
"session.question.restore": "Restaurer la question",
"session.question.pending.one": "{{count}} question en attente",
"session.question.pending.other": "{{count}} questions en attente",
"session.followupDock.summary.one": "{{count}} message en file d'attente",
"session.followupDock.summary.other": "{{count}} messages en file d'attente",
"session.followupDock.sendNow": "Envoyer maintenant",
@@ -496,9 +606,16 @@ export const dict = {
"session.revertDock.expand": "Développer les messages annulés",
"session.revertDock.restore": "Restaurer le message",
"session.new.title": "Créez ce que vous voulez",
"session.new.project.new": "Nouveau projet",
"session.new.project.search": "Rechercher des projets",
"session.new.project.add": "Ajouter un projet",
"session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
"session.new.worktree.create": "Créer un nouvel arbre de travail",
"session.new.workspace.runIn": "Exécuter la session dans",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Dépôt local",
"session.new.workspace.existing": "Espace de travail…",
"session.new.lastModified": "Dernière modification",
"session.header.search.placeholder": "Rechercher {{project}}",
"session.header.searchFiles": "Rechercher des fichiers",
@@ -564,6 +681,8 @@ export const dict = {
"sidebar.project.recentSessions": "Sessions récentes",
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
"sidebar.project.clearNotifications": "Effacer les notifications",
"sidebar.empty.title": "Aucun projet ouvert",
"sidebar.empty.description": "Ouvrez un projet pour commencer",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Bureau",
"settings.section.server": "Serveur",
@@ -573,6 +692,7 @@ export const dict = {
"settings.desktop.wsl.title": "Intégration WSL",
"settings.desktop.wsl.description": "Exécuter le serveur OpenCode dans WSL sur Windows.",
"settings.general.section.appearance": "Apparence",
"settings.general.section.advanced": "Avancé",
"settings.general.section.notifications": "Notifications système",
"settings.general.section.updates": "Mises à jour",
"settings.general.section.sounds": "Effets sonores",
@@ -580,6 +700,11 @@ export const dict = {
"settings.general.section.display": "Affichage",
"settings.general.row.language.title": "Langue",
"settings.general.row.language.description": "Changer la langue d'affichage pour OpenCode",
"settings.general.row.shell.title": "Shell du terminal",
"settings.general.row.shell.description":
"Choisissez le shell utilisé par votre terminal. Les shells compatibles sont également utilisés pour les appels d'outils de l'agent.",
"settings.general.row.shell.autoDefault": "Auto (par défaut)",
"settings.general.row.shell.terminalOnly": "terminal uniquement",
"settings.general.row.appearance.title": "Apparence",
"settings.general.row.appearance.description": "Personnaliser l'apparence d'OpenCode sur votre appareil",
"settings.general.row.colorScheme.title": "Schéma de couleurs",
@@ -588,8 +713,8 @@ export const dict = {
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police de code",
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Police du terminal",
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal",
"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",
@@ -597,6 +722,24 @@ export const dict = {
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
"settings.general.row.followup.option.queue": "File d'attente",
"settings.general.row.followup.option.steer": "Diriger",
"settings.general.row.showFileTree.title": "Arborescence des fichiers",
"settings.general.row.showFileTree.description": "Afficher le panneau d'arborescence des fichiers dans les sessions",
"settings.general.row.showNavigation.title": "Commandes de navigation",
"settings.general.row.showNavigation.description":
"Afficher les boutons précédent et suivant dans la barre de titre de l'application desktop",
"settings.general.row.showSearch.title": "Palette de commandes",
"settings.general.row.showSearch.description":
"Afficher le bouton de recherche et de palette de commandes dans la barre de titre",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description":
"Afficher le bouton du terminal dans la barre de titre de l'application desktop",
"settings.general.row.showStatus.title": "État du serveur",
"settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre",
"settings.general.row.mobileTitlebarBottom.title": "Navigation en bas",
"settings.general.row.mobileTitlebarBottom.description":
"Placer la barre de titre et les onglets de session en bas de l'écran sur mobile",
"settings.general.row.showCustomAgents.title": "Agents personnalisés",
"settings.general.row.showCustomAgents.description": "Afficher le sélecteur d'agent dans la zone de saisie",
"settings.general.row.reasoningSummaries.title": "Afficher les résumés de raisonnement",
"settings.general.row.reasoningSummaries.description":
"Afficher les résumés de raisonnement du modèle dans la chronologie",
@@ -606,6 +749,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs",
"settings.general.row.newLayoutDesigns.description":
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session",
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
"settings.general.row.pinchZoom.description":
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
"settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip":
@@ -618,6 +767,8 @@ export const dict = {
"settings.updates.row.check.description": "Vérifier manuellement les mises à jour et installer si disponible",
"settings.updates.action.checkNow": "Vérifier maintenant",
"settings.updates.action.checking": "Vérification...",
"settings.updates.action.downloading": "Téléchargement...",
"settings.updates.action.installing": "Installation...",
"settings.updates.toast.latest.title": "Vous êtes à jour",
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
"sound.option.none": "Aucun",
+151 -3
View File
@@ -21,6 +21,9 @@ export const dict = {
"theme.scheme.dark": "ダーク",
"command.sidebar.toggle": "サイドバーの切り替え",
"command.project.open": "プロジェクトを開く",
"command.project.previous": "前のプロジェクト",
"command.project.next": "次のプロジェクト",
"command.project.index": "プロジェクト{{index}}に切り替え",
"command.provider.connect": "プロバイダーに接続",
"command.server.switch": "サーバーの切り替え",
"command.settings.open": "設定を開く",
@@ -39,6 +42,7 @@ export const dict = {
"command.session.new": "新しいセッション",
"command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.tab.reopenClosed": "閉じたタブを再度開く",
"command.context.addSelection": "選択範囲をコンテキストに追加",
"command.context.addSelection.description": "現在のファイルから選択した行を追加",
"command.input.focus": "入力欄にフォーカス",
@@ -149,7 +153,7 @@ export const dict = {
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "小文字、数字、ハイフン、アンダースコア",
"provider.custom.field.name.label": "表示名",
"provider.custom.field.name.placeholder": "My AI Provider",
"provider.custom.field.name.placeholder": "マイAIプロバイダー",
"provider.custom.field.baseURL.label": "ベースURL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "APIキー",
@@ -192,6 +196,11 @@ export const dict = {
"model.input.video": "動画",
"model.input.pdf": "pdf",
"model.tooltip.allows": "対応: {{inputs}}",
"model.tooltip.context.label": "コンテキスト",
"model.tooltip.inputs": "入力",
"model.tooltip.model": "モデル",
"model.tooltip.provider": "プロバイダー",
"model.tooltip.reasoning": "推論",
"model.tooltip.reasoning.allowed": "推論を許可",
"model.tooltip.reasoning.none": "推論なし",
"model.tooltip.context": "コンテキスト上限 {{limit}}",
@@ -201,6 +210,7 @@ export const dict = {
"common.loading": "読み込み中",
"common.loading.ellipsis": "...",
"common.cancel": "キャンセル",
"common.clear": "クリア",
"common.connect": "接続",
"common.disconnect": "切断",
"common.continue": "送信",
@@ -217,6 +227,8 @@ export const dict = {
"prompt.mode.shell": "シェル",
"prompt.mode.normal": "プロンプト",
"prompt.mode.shell.exit": "escで終了",
"session.child.promptDisabled": "サブエージェントのセッションにはプロンプトを送信できません。",
"session.child.backToParent": "メインセッションに戻る。",
"prompt.example.1": "コードベースのTODOを修正",
"prompt.example.2": "このプロジェクトの技術スタックは何ですか?",
"prompt.example.3": "壊れたテストを修正",
@@ -313,6 +325,75 @@ export const dict = {
"dialog.server.menu.delete": "削除",
"dialog.server.current": "現在のサーバー",
"dialog.server.status.default": "デフォルト",
"wsl.server.add": "WSLサーバーを追加",
"wsl.server.addShort": "WSLを追加",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSLサーバー",
"wsl.server.retryStart": "起動を再試行",
"wsl.server.updating": "更新中...",
"wsl.onboarding.step.distro": "ディストリビューションを選択",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "WSLを確認中...",
"wsl.onboarding.restartRequired": "WSLのインストールを完了するにはWindowsの再起動が必要です。",
"wsl.onboarding.ready": "WSLの準備ができました。",
"wsl.onboarding.required": "続行するにはWSLが必要です。",
"wsl.onboarding.checkingDistros": "ディストリビューションを確認中...",
"wsl.onboarding.installingDistro": "{{distro}}をインストール中...",
"wsl.onboarding.checkingDistro": "{{distro}}を確認中...",
"wsl.onboarding.listingDistros": "ディストリビューションの一覧を取得中...",
"wsl.onboarding.distroReady": "{{distro}}の準備ができました。",
"wsl.onboarding.distroNotInstalled": "{{distro}}はまだインストールされていません。",
"wsl.onboarding.openDistroOnce": "セットアップを完了するには、{{distro}}を一度開いてください。",
"wsl.onboarding.finishingDistro": "{{distro}}のセットアップを完了しています。",
"wsl.onboarding.pickDistro": "ディストリビューションを選択するか、以下からインストールしてください。",
"wsl.onboarding.checkingOpencode": "OpenCodeを確認中...",
"wsl.onboarding.checkingOpencodeIn": "{{distro}}内のOpenCodeを確認中...",
"wsl.onboarding.updatingOpencode": "OpenCodeを更新中...",
"wsl.onboarding.updatingOpencodeIn": "{{distro}}内のOpenCodeを更新中...",
"wsl.onboarding.updateOpencodeIn": "{{distro}}内のOpenCodeを更新してください。",
"wsl.onboarding.updateOpencode": "OpenCodeを更新",
"wsl.onboarding.opencodeReadyIn": "{{distro}}内のOpenCodeの準備ができました。",
"wsl.onboarding.opencodeReady": "OpenCodeの準備ができました。",
"wsl.onboarding.installOpencodeIn": "{{distro}}にOpenCodeをインストールしてください。",
"wsl.onboarding.installOpencode": "OpenCodeをインストール",
"wsl.onboarding.chooseDistroFirst": "先にディストリビューションを選択してください。",
"wsl.onboarding.loadFailed": "WSLの状態を読み込めませんでした。",
"wsl.onboarding.loading": "読み込み中...",
"wsl.onboarding.installedDistros": "インストール済みのディストリビューション",
"wsl.onboarding.checkAgain": "再確認",
"wsl.onboarding.distroStatus.ready": "準備完了",
"wsl.onboarding.distroStatus.checking": "確認中...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCodeがインストールされていません",
"wsl.onboarding.distroStatus.missingTools": "bash、curlがありません",
"wsl.onboarding.distroStatus.unsupported": "非対応 · WSL 2を使用してください",
"wsl.onboarding.needAnotherDistro": "別のディストリビューションが必要ですか?",
"wsl.onboarding.needAnotherDistroHint": "WSLカタログからLinuxディストリビューションをインストール",
"wsl.onboarding.wslNotInstalled.title": "WSLがインストールされていません",
"wsl.onboarding.wslNotInstalled.description":
"OpenCodeでWSLサーバーを追加するには、WSL (Windows Subsystem for Linux) が必要です",
"wsl.onboarding.wslUnavailable.title": "WSLを利用できません",
"wsl.onboarding.wslUnavailable.description": "このマシンのWSLを確認できませんでした。",
"wsl.onboarding.installWsl": "WSLをインストール",
"wsl.onboarding.windowsRestartRequired":
"WSLのインストールを完了するにはWindowsを再起動し、OpenCodeをもう一度開いてください。",
"wsl.onboarding.next": "次へ",
"wsl.onboarding.refresh": "更新",
"wsl.onboarding.allDistrosAdded": "インストール済みのディストリビューションはすべて追加済みです。",
"wsl.onboarding.noDistros": "ディストリビューションはまだ検出されていません。",
"wsl.onboarding.install": "インストール",
"wsl.onboarding.installing": "インストール中...",
"wsl.onboarding.installDistro": "ディストリビューションをインストール",
"wsl.onboarding.searchDistros": "ディストリビューションを検索",
"wsl.onboarding.wsl2Required": "WSL 2が必要です。",
"wsl.onboarding.toolsRequired": "このディストリビューションにはbashとcurlが必要です。",
"wsl.onboarding.openTerminal": "ターミナルを開く",
"wsl.onboarding.path": "パス: {{path}}",
"wsl.onboarding.notFound": "見つかりません",
"wsl.onboarding.version": "バージョン: {{version}}",
"wsl.onboarding.unknown": "不明",
"wsl.onboarding.desktopVersion": "デスクトップ {{version}}",
"wsl.onboarding.versionMismatch": "インストールされているバージョンがデスクトップアプリのバージョンと一致しません。",
"wsl.onboarding.adding": "追加中...",
"dialog.project.edit.title": "プロジェクトを編集",
"dialog.project.edit.name": "名前",
"dialog.project.edit.icon": "アイコン",
@@ -370,6 +451,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "言語",
@@ -406,10 +488,12 @@ export const dict = {
"toast.update.action.notYet": "今はしない",
"error.page.title": "問題が発生しました",
"error.page.description": "アプリケーションの読み込み中にエラーが発生しました。",
"error.page.description.localServerStartup": "ローカルサーバーの起動中にエラーが発生しました。",
"error.page.details.label": "エラー詳細",
"error.page.action.restart": "再起動",
"error.page.action.report": "エラーを報告",
"error.page.action.reported": "エラーを報告しました",
"error.page.action.exportLogs": "ログをエクスポート",
"error.page.action.checking": "確認中...",
"error.page.action.checkUpdates": "アップデートを確認",
"error.page.action.updateTo": "{{version}}にアップデート",
@@ -451,10 +535,30 @@ export const dict = {
"home.recentProjects": "最近のプロジェクト",
"home.empty.title": "最近のプロジェクトはありません",
"home.empty.description": "ローカルプロジェクトを開いて始めましょう",
"home.title": "ホーム",
"home.projects": "プロジェクト",
"home.project.add": "プロジェクトを追加",
"home.recentlyClosed": "最近閉じたプロジェクト",
"home.server.collapse": "サーバーのプロジェクトを折りたたむ",
"home.server.expand": "サーバーのプロジェクトを展開",
"home.sessions.search.placeholder": "セッションを検索",
"home.sessions.search.placeholder.scoped": "{{scope}}のセッションを検索",
"home.sessions.search.sessions": "セッション",
"home.sessions.search.noResults": "{{query}}に一致するセッションが見つかりません",
"home.sessions.empty": "まだ何もありません",
"home.sessions.empty.description": "セッションを作成して始めましょう",
"home.sessions.group.today": "今日",
"home.sessions.group.yesterday": "昨日",
"home.sessions.group.older": "それ以前",
"session.tab.session": "セッション",
"session.tab.review": "レビュー",
"session.tab.context": "コンテキスト",
"session.tab.unknown": "不明なセッション",
"session.panel.reviewAndFiles": "レビューとファイル",
"session.error.notFound": "このセッションが見つかりません",
"session.error.notFound.description": "このタブが参照するセッションは、このサーバーには存在しません。",
"session.error.notFound.closeTab": "タブを閉じる",
"session.error.serverConnection": "このサーバーに接続できません",
"session.review.filesChanged": "{{count}} ファイル変更",
"session.review.change.one": "変更",
"session.review.change.other": "変更",
@@ -463,6 +567,8 @@ export const dict = {
"session.review.noVcs": "Gitバージョン管理システムが検出されないため、変更は表示されません",
"session.review.noSnapshot": "設定でスナップショット追跡が無効になっているため、セッションの変更は利用できません",
"session.review.noChanges": "変更なし",
"session.review.noUncommittedChanges": "コミットされていない変更はまだありません",
"session.review.noBranchChanges": "ブランチの変更はまだありません",
"session.files.selectToOpen": "開くファイルを選択",
"session.files.all": "すべてのファイル",
"session.files.empty": "ファイルなし",
@@ -476,6 +582,10 @@ export const dict = {
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.question.minimize": "質問を最小化",
"session.question.restore": "質問を復元",
"session.question.pending.one": "{{count}}件の保留中の質問",
"session.question.pending.other": "{{count}}件の保留中の質問",
"session.followupDock.summary.one": "{{count}} 件のメッセージが待機中",
"session.followupDock.summary.other": "{{count}} 件のメッセージが待機中",
"session.followupDock.sendNow": "今すぐ送信",
@@ -488,9 +598,16 @@ export const dict = {
"session.revertDock.expand": "ロールバックされたメッセージを展開",
"session.revertDock.restore": "メッセージを復元",
"session.new.title": "何でも作る",
"session.new.project.new": "新しいプロジェクト",
"session.new.project.search": "プロジェクトを検索",
"session.new.project.add": "プロジェクトを追加",
"session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
"session.new.worktree.create": "新しいワークツリーを作成",
"session.new.workspace.runIn": "セッションの実行先",
"session.new.workspace.triggerLocal": "ローカル",
"session.new.workspace.local": "ローカルリポジトリ",
"session.new.workspace.existing": "ワークスペース…",
"session.new.lastModified": "最終更新",
"session.header.search.placeholder": "{{project}}を検索",
"session.header.searchFiles": "ファイルを検索",
@@ -554,6 +671,8 @@ export const dict = {
"sidebar.project.recentSessions": "最近のセッション",
"sidebar.project.viewAllSessions": "すべてのセッションを表示",
"sidebar.project.clearNotifications": "通知をクリア",
"sidebar.empty.title": "開いているプロジェクトはありません",
"sidebar.empty.description": "プロジェクトを開いて始めましょう",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "デスクトップ",
"settings.section.server": "サーバー",
@@ -563,6 +682,7 @@ export const dict = {
"settings.desktop.wsl.title": "WSL連携",
"settings.desktop.wsl.description": "WindowsのWSL環境でOpenCodeサーバーを実行します。",
"settings.general.section.appearance": "外観",
"settings.general.section.advanced": "詳細設定",
"settings.general.section.notifications": "システム通知",
"settings.general.section.updates": "アップデート",
"settings.general.section.sounds": "効果音",
@@ -570,6 +690,11 @@ export const dict = {
"settings.general.section.display": "ディスプレイ",
"settings.general.row.language.title": "言語",
"settings.general.row.language.description": "OpenCodeの表示言語を変更します",
"settings.general.row.shell.title": "ターミナルシェル",
"settings.general.row.shell.description":
"ターミナルで使用するシェルを選択します。互換性のあるシェルはエージェントのツール呼び出しにも使用されます。",
"settings.general.row.shell.autoDefault": "自動 (デフォルト)",
"settings.general.row.shell.terminalOnly": "ターミナルのみ",
"settings.general.row.appearance.title": "外観",
"settings.general.row.appearance.description": "デバイスでのOpenCodeの表示をカスタマイズします",
"settings.general.row.colorScheme.title": "配色",
@@ -578,8 +703,8 @@ export const dict = {
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
"settings.general.row.font.title": "コードフォント",
"settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "ターミナルのフォント",
"settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
@@ -587,6 +712,21 @@ export const dict = {
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
"settings.general.row.followup.option.queue": "キューに追加",
"settings.general.row.followup.option.steer": "即座に実行 (Steer)",
"settings.general.row.showFileTree.title": "ファイルツリー",
"settings.general.row.showFileTree.description": "セッションにファイルツリーパネルを表示します",
"settings.general.row.showNavigation.title": "ナビゲーションコントロール",
"settings.general.row.showNavigation.description": "デスクトップのタイトルバーに戻るボタンと進むボタンを表示します",
"settings.general.row.showSearch.title": "コマンドパレット",
"settings.general.row.showSearch.description": "タイトルバーに検索とコマンドパレットのボタンを表示します",
"settings.general.row.showTerminal.title": "ターミナル",
"settings.general.row.showTerminal.description": "デスクトップのタイトルバーにターミナルボタンを表示します",
"settings.general.row.showStatus.title": "サーバーステータス",
"settings.general.row.showStatus.description": "タイトルバーにサーバーステータスボタンを表示します",
"settings.general.row.mobileTitlebarBottom.title": "下部ナビゲーション",
"settings.general.row.mobileTitlebarBottom.description":
"モバイルではタイトルバーとセッションタブを画面下部に配置します",
"settings.general.row.showCustomAgents.title": "カスタムエージェント",
"settings.general.row.showCustomAgents.description": "コンポーザーにエージェント選択を表示します",
"settings.general.row.reasoningSummaries.title": "推論の要約を表示",
"settings.general.row.reasoningSummaries.description": "タイムラインにモデルの推論の要約を表示します",
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
@@ -595,6 +735,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン",
"settings.general.row.newLayoutDesigns.description":
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします",
"settings.general.row.pinchZoom.title": "ピンチでズーム",
"settings.general.row.pinchZoom.description":
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip":
@@ -607,6 +753,8 @@ export const dict = {
"settings.updates.row.check.description": "手動でアップデートを確認し、利用可能ならインストールします",
"settings.updates.action.checkNow": "今すぐ確認",
"settings.updates.action.checking": "確認中...",
"settings.updates.action.downloading": "ダウンロード中...",
"settings.updates.action.installing": "インストール中...",
"settings.updates.toast.latest.title": "最新です",
"settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
"sound.option.none": "なし",
+153 -2
View File
@@ -575,8 +575,8 @@ export const dict = {
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
"settings.general.row.font.title": "코드 글꼴",
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "터미널 글꼴",
"settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
@@ -848,4 +848,155 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
"error.childStore.storeCreateFailed": "저장소 생성 실패",
"terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}",
"command.project.previous": "이전 프로젝트",
"command.project.next": "다음 프로젝트",
"command.project.index": "{{index}}번 프로젝트로 전환",
"command.tab.reopenClosed": "닫은 탭 다시 열기",
"model.tooltip.context.label": "컨텍스트",
"model.tooltip.inputs": "입력",
"model.tooltip.model": "모델",
"model.tooltip.provider": "공급자",
"model.tooltip.reasoning": "추론",
"common.clear": "지우기",
"session.child.promptDisabled": "하위 에이전트 세션에는 프롬프트를 입력할 수 없습니다.",
"session.child.backToParent": "메인 세션으로 돌아가기.",
"wsl.server.add": "WSL 서버 추가",
"wsl.server.addShort": "WSL 추가",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL 서버",
"wsl.server.retryStart": "시작 재시도",
"wsl.server.updating": "업데이트 중...",
"wsl.onboarding.step.distro": "배포판 선택",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "WSL 확인 중...",
"wsl.onboarding.restartRequired": "WSL 설치를 완료하려면 Windows를 다시 시작해야 합니다.",
"wsl.onboarding.ready": "WSL이 준비되었습니다.",
"wsl.onboarding.required": "계속하려면 WSL이 필요합니다.",
"wsl.onboarding.checkingDistros": "배포판 확인 중...",
"wsl.onboarding.installingDistro": "{{distro}} 배포판 설치 중...",
"wsl.onboarding.checkingDistro": "{{distro}} 배포판 확인 중...",
"wsl.onboarding.listingDistros": "배포판 목록 확인 중...",
"wsl.onboarding.distroReady": "{{distro}} 배포판이 준비되었습니다.",
"wsl.onboarding.distroNotInstalled": "{{distro}} 배포판이 아직 설치되지 않았습니다.",
"wsl.onboarding.openDistroOnce": "설정을 완료하려면 {{distro}} 배포판을 한 번 여세요.",
"wsl.onboarding.finishingDistro": "{{distro}} 배포판 설정을 마무리하는 중입니다.",
"wsl.onboarding.pickDistro": "배포판을 선택하거나 아래에서 설치하세요.",
"wsl.onboarding.checkingOpencode": "OpenCode 확인 중...",
"wsl.onboarding.checkingOpencodeIn": "{{distro}}에서 OpenCode 확인 중...",
"wsl.onboarding.updatingOpencode": "OpenCode 업데이트 중...",
"wsl.onboarding.updatingOpencodeIn": "{{distro}}에서 OpenCode 업데이트 중...",
"wsl.onboarding.updateOpencodeIn": "{{distro}}에서 OpenCode를 업데이트하세요.",
"wsl.onboarding.updateOpencode": "OpenCode 업데이트",
"wsl.onboarding.opencodeReadyIn": "{{distro}}에서 OpenCode를 사용할 준비가 되었습니다.",
"wsl.onboarding.opencodeReady": "OpenCode가 준비되었습니다.",
"wsl.onboarding.installOpencodeIn": "{{distro}}에 OpenCode를 설치하세요.",
"wsl.onboarding.installOpencode": "OpenCode 설치",
"wsl.onboarding.chooseDistroFirst": "먼저 배포판을 선택하세요.",
"wsl.onboarding.loadFailed": "WSL 상태를 불러오지 못했습니다.",
"wsl.onboarding.loading": "로딩 중...",
"wsl.onboarding.installedDistros": "설치된 배포판",
"wsl.onboarding.checkAgain": "다시 확인",
"wsl.onboarding.distroStatus.ready": "준비됨",
"wsl.onboarding.distroStatus.checking": "확인 중...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode가 설치되지 않음",
"wsl.onboarding.distroStatus.missingTools": "bash, curl 없음",
"wsl.onboarding.distroStatus.unsupported": "지원되지 않음 · WSL 2 사용",
"wsl.onboarding.needAnotherDistro": "다른 배포판이 필요하신가요?",
"wsl.onboarding.needAnotherDistroHint": "WSL 카탈로그에서 Linux 배포판을 설치하세요",
"wsl.onboarding.wslNotInstalled.title": "WSL이 설치되지 않음",
"wsl.onboarding.wslNotInstalled.description":
"OpenCode에서 WSL 서버를 추가하려면 WSL (Windows Subsystem for Linux)이 필요합니다",
"wsl.onboarding.wslUnavailable.title": "WSL을 사용할 수 없음",
"wsl.onboarding.wslUnavailable.description": "OpenCode에서 이 컴퓨터의 WSL을 확인할 수 없습니다.",
"wsl.onboarding.installWsl": "WSL 설치",
"wsl.onboarding.windowsRestartRequired": "WSL 설치를 완료하려면 Windows를 다시 시작한 다음 OpenCode를 다시 여세요.",
"wsl.onboarding.next": "다음",
"wsl.onboarding.refresh": "새로 고침",
"wsl.onboarding.allDistrosAdded": "설치된 모든 배포판이 이미 추가되었습니다.",
"wsl.onboarding.noDistros": "아직 감지된 배포판이 없습니다.",
"wsl.onboarding.install": "설치",
"wsl.onboarding.installing": "설치 중...",
"wsl.onboarding.installDistro": "배포판 설치",
"wsl.onboarding.searchDistros": "배포판 검색",
"wsl.onboarding.wsl2Required": "WSL 2가 필요합니다.",
"wsl.onboarding.toolsRequired": "이 배포판에는 bash와 curl이 필요합니다.",
"wsl.onboarding.openTerminal": "터미널 열기",
"wsl.onboarding.path": "경로: {{path}}",
"wsl.onboarding.notFound": "찾을 수 없음",
"wsl.onboarding.version": "버전: {{version}}",
"wsl.onboarding.unknown": "알 수 없음",
"wsl.onboarding.desktopVersion": "데스크톱 {{version}}",
"wsl.onboarding.versionMismatch": "설치된 버전이 데스크톱 앱 버전과 일치하지 않습니다.",
"wsl.onboarding.adding": "추가 중...",
"language.uk": "Українська",
"error.page.description.localServerStartup": "로컬 서버를 시작하는 동안 오류가 발생했습니다.",
"error.page.action.exportLogs": "로그 내보내기",
"home.title": "홈",
"home.projects": "프로젝트",
"home.project.add": "프로젝트 추가",
"home.recentlyClosed": "최근에 닫은 항목",
"home.server.collapse": "서버 프로젝트 접기",
"home.server.expand": "서버 프로젝트 펼치기",
"home.sessions.search.placeholder": "세션 검색",
"home.sessions.search.placeholder.scoped": "{{scope}}에서 세션 검색",
"home.sessions.search.sessions": "세션",
"home.sessions.search.noResults": "{{query}}에 해당하는 세션을 찾을 수 없습니다",
"home.sessions.empty": "아직 아무것도 없습니다",
"home.sessions.empty.description": "세션을 생성하여 시작하세요",
"home.sessions.group.today": "오늘",
"home.sessions.group.yesterday": "어제",
"home.sessions.group.older": "이전",
"session.tab.unknown": "알 수 없는 세션",
"session.error.notFound": "이 세션을 찾을 수 없습니다",
"session.error.notFound.description": "이 탭은 이 서버에 더 이상 존재하지 않는 세션을 가리킵니다.",
"session.error.notFound.closeTab": "탭 닫기",
"session.error.serverConnection": "이 서버에 연결할 수 없습니다",
"session.review.noUncommittedChanges": "아직 커밋되지 않은 변경 사항이 없습니다",
"session.review.noBranchChanges": "아직 브랜치 변경 사항이 없습니다",
"session.question.minimize": "질문 최소화",
"session.question.restore": "질문 복원",
"session.question.pending.one": "{{count}}개의 대기 중인 질문",
"session.question.pending.other": "{{count}}개의 대기 중인 질문",
"session.new.project.new": "새 프로젝트",
"session.new.project.search": "프로젝트 검색",
"session.new.project.add": "프로젝트 추가",
"session.new.workspace.runIn": "세션 실행 위치",
"session.new.workspace.triggerLocal": "로컬",
"session.new.workspace.local": "로컬 저장소",
"session.new.workspace.existing": "작업 공간…",
"sidebar.empty.title": "열린 프로젝트 없음",
"sidebar.empty.description": "프로젝트를 열어 시작하세요",
"settings.general.section.advanced": "고급",
"settings.general.row.shell.title": "터미널 셸",
"settings.general.row.shell.description":
"터미널에서 사용할 셸을 선택하세요. 호환되는 셸은 에이전트 도구 호출에도 사용됩니다.",
"settings.general.row.shell.autoDefault": "자동 (기본값)",
"settings.general.row.shell.terminalOnly": "터미널 전용",
"settings.general.row.showFileTree.title": "파일 트리",
"settings.general.row.showFileTree.description": "세션에 파일 트리 패널 표시",
"settings.general.row.showNavigation.title": "탐색 컨트롤",
"settings.general.row.showNavigation.description": "데스크톱 제목 표시줄에 뒤로 및 앞으로 버튼 표시",
"settings.general.row.showSearch.title": "명령 팔레트",
"settings.general.row.showSearch.description": "제목 표시줄에 검색 및 명령 팔레트 버튼 표시",
"settings.general.row.showTerminal.title": "터미널",
"settings.general.row.showTerminal.description": "데스크톱 제목 표시줄에 터미널 버튼 표시",
"settings.general.row.showStatus.title": "서버 상태",
"settings.general.row.showStatus.description": "제목 표시줄에 서버 상태 버튼 표시",
"settings.general.row.mobileTitlebarBottom.title": "하단 탐색",
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인",
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화",
"settings.general.row.pinchZoom.title": "핀치 줌",
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
"settings.updates.action.downloading": "다운로드 중...",
"settings.updates.action.installing": "설치 중...",
}
+163 -4
View File
@@ -77,7 +77,7 @@ export const dict = {
"command.permissions.autoaccept.enable": "Aksepter tillatelser automatisk",
"command.permissions.autoaccept.disable": "Stopp automatisk akseptering av tillatelser",
"command.workspace.toggle": "Veksle arbeidsområder",
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
"command.workspace.toggle.description": "Aktiver eller deaktiver flere arbeidsområder i sidefeltet",
"command.session.undo": "Angre",
"command.session.undo.description": "Angre siste melding",
"command.session.redo": "Gjør om",
@@ -469,7 +469,7 @@ export const dict = {
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
"error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"directory.error.invalidUrl": "Invalid directory in URL.",
"directory.error.invalidUrl": "Ugyldig mappe i URL-en.",
"error.chain.unknown": "Ukjent feil",
"error.chain.causedBy": "Forårsaket av:",
@@ -649,8 +649,8 @@ export const dict = {
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
"settings.general.row.font.title": "Kodefont",
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Terminalskrift",
"settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen",
"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",
@@ -936,4 +936,163 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
"error.childStore.storeCreateFailed": "Kunne ikke opprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukket unormalt: {{code}}",
"command.project.previous": "Forrige prosjekt",
"command.project.next": "Neste prosjekt",
"command.project.index": "Bytt til prosjekt {{index}}",
"command.tab.reopenClosed": "Åpne lukket fane på nytt",
"model.tooltip.context.label": "Kontekst",
"model.tooltip.inputs": "Inndata",
"model.tooltip.model": "Modell",
"model.tooltip.provider": "Leverandør",
"model.tooltip.reasoning": "Resonnering",
"common.clear": "Tøm",
"session.child.promptDisabled": "Underagentsesjoner kan ikke motta forespørsler.",
"session.child.backToParent": "Tilbake til hovedsesjonen.",
"wsl.server.add": "Legg til WSL-server",
"wsl.server.addShort": "Legg til WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL-server",
"wsl.server.retryStart": "Prøv å starte på nytt",
"wsl.server.updating": "Oppdaterer...",
"wsl.onboarding.step.distro": "Velg distribusjon",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Sjekker WSL...",
"wsl.onboarding.restartRequired": "Windows må startes på nytt for å fullføre installasjonen av WSL.",
"wsl.onboarding.ready": "WSL er klart.",
"wsl.onboarding.required": "WSL kreves for å fortsette.",
"wsl.onboarding.checkingDistros": "Sjekker distribusjoner...",
"wsl.onboarding.installingDistro": "Installerer {{distro}}...",
"wsl.onboarding.checkingDistro": "Sjekker {{distro}}...",
"wsl.onboarding.listingDistros": "Lister distribusjoner...",
"wsl.onboarding.distroReady": "{{distro}} er klar.",
"wsl.onboarding.distroNotInstalled": "{{distro}} er ikke installert ennå.",
"wsl.onboarding.openDistroOnce": "Åpne {{distro}} én gang for å fullføre oppsettet.",
"wsl.onboarding.finishingDistro": "Fullfører oppsettet for {{distro}}.",
"wsl.onboarding.pickDistro": "Velg en distribusjon eller installer en nedenfor.",
"wsl.onboarding.checkingOpencode": "Sjekker OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Sjekker OpenCode i {{distro}}...",
"wsl.onboarding.updatingOpencode": "Oppdaterer OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Oppdaterer OpenCode i {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Oppdater OpenCode i {{distro}}.",
"wsl.onboarding.updateOpencode": "Oppdater OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode er klar i {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode er klar.",
"wsl.onboarding.installOpencodeIn": "Installer OpenCode i {{distro}}.",
"wsl.onboarding.installOpencode": "Installer OpenCode",
"wsl.onboarding.chooseDistroFirst": "Velg en distribusjon først.",
"wsl.onboarding.loadFailed": "Kunne ikke laste inn WSL-status.",
"wsl.onboarding.loading": "Laster...",
"wsl.onboarding.installedDistros": "Installerte distribusjoner",
"wsl.onboarding.checkAgain": "Sjekk på nytt",
"wsl.onboarding.distroStatus.ready": "Klar",
"wsl.onboarding.distroStatus.checking": "Sjekker...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode er ikke installert",
"wsl.onboarding.distroStatus.missingTools": "Mangler bash, curl",
"wsl.onboarding.distroStatus.unsupported": "Ikke støttet · Bruk WSL 2",
"wsl.onboarding.needAnotherDistro": "Trenger du en annen distribusjon?",
"wsl.onboarding.needAnotherDistroHint": "Installer en Linux-distribusjon fra WSL-katalogen",
"wsl.onboarding.wslNotInstalled.title": "WSL er ikke installert",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) kreves før OpenCode kan legge til en WSL-server",
"wsl.onboarding.wslUnavailable.title": "WSL er utilgjengelig",
"wsl.onboarding.wslUnavailable.description": "OpenCode kunne ikke bekrefte WSL på denne maskinen.",
"wsl.onboarding.installWsl": "Installer WSL",
"wsl.onboarding.windowsRestartRequired":
"Start Windows på nytt for å fullføre installasjonen av WSL, og åpne deretter OpenCode på nytt.",
"wsl.onboarding.next": "Neste",
"wsl.onboarding.refresh": "Oppdater",
"wsl.onboarding.allDistrosAdded": "Alle installerte distribusjoner er allerede lagt til.",
"wsl.onboarding.noDistros": "Ingen distribusjoner oppdaget ennå.",
"wsl.onboarding.install": "Installer",
"wsl.onboarding.installing": "Installerer...",
"wsl.onboarding.installDistro": "Installer distribusjon",
"wsl.onboarding.searchDistros": "Søk etter distribusjoner",
"wsl.onboarding.wsl2Required": "WSL 2 er påkrevd.",
"wsl.onboarding.toolsRequired": "Denne distribusjonen trenger bash og curl.",
"wsl.onboarding.openTerminal": "Åpne terminal",
"wsl.onboarding.path": "Bane: {{path}}",
"wsl.onboarding.notFound": "ikke funnet",
"wsl.onboarding.version": "Versjon: {{version}}",
"wsl.onboarding.unknown": "ukjent",
"wsl.onboarding.desktopVersion": "skrivebordsapp {{version}}",
"wsl.onboarding.versionMismatch": "Den installerte versjonen samsvarer ikke med versjonen av skrivebordsappen.",
"wsl.onboarding.adding": "Legger til...",
"language.uk": "Українська",
"error.page.description.localServerStartup": "Det oppstod en feil under oppstart av den lokale serveren.",
"error.page.action.exportLogs": "Eksporter logger",
"home.title": "Hjem",
"home.projects": "Prosjekter",
"home.project.add": "Legg til prosjekt",
"home.recentlyClosed": "Nylig lukket",
"home.server.collapse": "Fold sammen serverprosjekter",
"home.server.expand": "Utvid serverprosjekter",
"home.sessions.search.placeholder": "Søk etter sesjoner",
"home.sessions.search.placeholder.scoped": "Søk etter sesjoner i {{scope}}",
"home.sessions.search.sessions": "Sesjoner",
"home.sessions.search.noResults": "Ingen sesjoner funnet for {{query}}",
"home.sessions.empty": "Ingenting her ennå",
"home.sessions.empty.description": "Opprett en sesjon for å komme i gang",
"home.sessions.group.today": "I dag",
"home.sessions.group.yesterday": "I går",
"home.sessions.group.older": "Eldre",
"session.tab.unknown": "Ukjent sesjon",
"session.error.notFound": "Denne sesjonen finnes ikke",
"session.error.notFound.description": "Denne fanen peker til en sesjon som ikke lenger finnes på denne serveren.",
"session.error.notFound.closeTab": "Lukk fane",
"session.error.serverConnection": "Kan ikke koble til denne serveren",
"session.review.noUncommittedChanges": "Ingen ucommittede endringer ennå",
"session.review.noBranchChanges": "Ingen grenendringer ennå",
"session.question.minimize": "Minimer spørsmål",
"session.question.restore": "Gjenopprett spørsmål",
"session.question.pending.one": "{{count}} ventende spørsmål",
"session.question.pending.other": "{{count}} ventende spørsmål",
"session.new.project.new": "Nytt prosjekt",
"session.new.project.search": "Søk etter prosjekter",
"session.new.project.add": "Legg til prosjekt",
"session.new.workspace.runIn": "Kjør sesjonen i",
"session.new.workspace.triggerLocal": "Lokalt",
"session.new.workspace.local": "Lokalt depot",
"session.new.workspace.existing": "Arbeidsområde…",
"sidebar.empty.title": "Ingen åpne prosjekter",
"sidebar.empty.description": "Åpne et prosjekt for å komme i gang",
"settings.general.section.advanced": "Avansert",
"settings.general.row.shell.title": "Terminalskall",
"settings.general.row.shell.description":
"Velg skallet som skal brukes i terminalen. Kompatible skall brukes også til agentens verktøykall.",
"settings.general.row.shell.autoDefault": "Automatisk (standard)",
"settings.general.row.shell.terminalOnly": "kun terminal",
"settings.general.row.showFileTree.title": "Filtre",
"settings.general.row.showFileTree.description": "Vis filtrepanelet i sesjoner",
"settings.general.row.showNavigation.title": "Navigasjonskontroller",
"settings.general.row.showNavigation.description":
"Vis tilbake- og fremoverknappene på tittellinjen i skrivebordsappen",
"settings.general.row.showSearch.title": "Kommandopalett",
"settings.general.row.showSearch.description": "Vis knappen for søk og kommandopalett på tittellinjen",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Vis terminalknappen på tittellinjen i skrivebordsappen",
"settings.general.row.showStatus.title": "Serverstatus",
"settings.general.row.showStatus.description": "Vis serverstatusknappen på tittellinjen",
"settings.general.row.mobileTitlebarBottom.title": "Navigasjon nederst",
"settings.general.row.mobileTitlebarBottom.description":
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
"settings.general.row.newLayoutDesigns.title": "Ny layout og utforming",
"settings.general.row.newLayoutDesigns.description":
"Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet",
"settings.general.row.pinchZoom.title": "Knip for å zoome",
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
"settings.updates.action.downloading": "Laster ned...",
"settings.updates.action.installing": "Installerer...",
} satisfies Partial<Record<Keys, string>>
+110 -25
View File
@@ -1,33 +1,118 @@
import { describe, expect, test } from "bun:test"
import { dict as en } from "./en"
import { dict as ar } from "./ar"
import { dict as br } from "./br"
import { dict as bs } from "./bs"
import { dict as da } from "./da"
import { dict as de } from "./de"
import { dict as es } from "./es"
import { dict as fr } from "./fr"
import { dict as ja } from "./ja"
import { dict as ko } from "./ko"
import { dict as no } from "./no"
import { dict as pl } from "./pl"
import { dict as ru } from "./ru"
import { dict as uk } from "./uk"
import { dict as th } from "./th"
import { dict as zh } from "./zh"
import { dict as zht } from "./zht"
import { dict as tr } from "./tr"
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, uk, th, tr, zh, zht]
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
const appLocales = [
"ar",
"br",
"bs",
"da",
"de",
"es",
"fr",
"ja",
"ko",
"no",
"pl",
"ru",
"uk",
"th",
"tr",
"zh",
"zht",
] as const
const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr")
const domains = [
{
name: "app",
source: "./en.ts",
target: (locale: string) => `./${locale}.ts`,
locales: appLocales,
},
{
name: "ui",
source: "../../../ui/src/i18n/en.ts",
target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`,
locales: appLocales,
},
{
name: "desktop",
source: "../../../desktop/src/renderer/i18n/en.ts",
target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`,
locales: desktopLocales,
},
] as const
describe("i18n parity", () => {
test("non-English locales translate targeted unseen session keys", () => {
for (const locale of locales) {
for (const key of keys) {
expect(locale[key]).toBeDefined()
expect(locale[key]).not.toBe(en[key])
test("non-English locales have every English key", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
const extra = Object.keys(target).filter((key) => !Object.hasOwn(source, key))
expect({ domain: domain.name, locale, missing, extra }).toEqual({
domain: domain.name,
locale,
missing: [],
extra: [],
})
}
}
})
test("non-English locales preserve English placeholders", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const mismatched = Object.keys(source).filter(
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
)
expect({ domain: domain.name, locale, mismatched }).toEqual({ domain: domain.name, locale, mismatched: [] })
}
}
})
test("non-English locales translate targeted unseen session keys", async () => {
const source = await dictionary("./en.ts")
for (const locale of appLocales) {
const target = await dictionary(`./${locale}.ts`)
for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) {
expect(target[key]).toBeDefined()
expect(target[key]).not.toBe(source[key])
}
}
})
test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => {
const source = await dictionary("../../../ui/src/i18n/en.ts")
expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file")
expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files")
expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined()
for (const locale of appLocales) {
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) {
expect(target[key].trim()).not.toBe("")
expect(placeholders(target[key])).toEqual(["count"])
}
}
})
})
async function dictionary(file: string) {
const module: unknown = await import(file)
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
throw new Error(`Invalid translation dictionary: ${file}`)
}
return module.dict
}
function isDictionary(value: unknown): value is Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
return Object.values(value).every((item) => typeof item === "string")
}
function placeholders(value: string) {
return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort()
}
+157 -6
View File
@@ -21,6 +21,9 @@ export const dict = {
"theme.scheme.dark": "Ciemny",
"command.sidebar.toggle": "Przełącz pasek boczny",
"command.project.open": "Otwórz projekt",
"command.project.previous": "Poprzedni projekt",
"command.project.next": "Następny projekt",
"command.project.index": "Przełącz na projekt {{index}}",
"command.provider.connect": "Połącz dostawcę",
"command.server.switch": "Przełącz serwer",
"command.settings.open": "Otwórz ustawienia",
@@ -39,6 +42,7 @@ export const dict = {
"command.session.new": "Nowa sesja",
"command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.tab.reopenClosed": "Otwórz ponownie zamkniętą kartę",
"command.context.addSelection": "Dodaj zaznaczenie do kontekstu",
"command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku",
"command.input.focus": "Fokus na pole wejściowe",
@@ -194,6 +198,11 @@ export const dict = {
"model.input.video": "wideo",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Obsługuje: {{inputs}}",
"model.tooltip.context.label": "Kontekst",
"model.tooltip.inputs": "Dane wejściowe",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Dostawca",
"model.tooltip.reasoning": "Wnioskowanie",
"model.tooltip.reasoning.allowed": "Obsługuje wnioskowanie",
"model.tooltip.reasoning.none": "Brak wnioskowania",
"model.tooltip.context": "Limit kontekstu {{limit}}",
@@ -203,6 +212,7 @@ export const dict = {
"common.loading": "Ładowanie",
"common.loading.ellipsis": "...",
"common.cancel": "Anuluj",
"common.clear": "Wyczyść",
"common.connect": "Połącz",
"common.disconnect": "Rozłącz",
"common.continue": "Prześlij",
@@ -219,6 +229,8 @@ export const dict = {
"prompt.mode.shell": "Terminal",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc aby wyjść",
"session.child.promptDisabled": "Nie można wysyłać promptów w sesjach podagentów.",
"session.child.backToParent": "Wróć do głównej sesji.",
"prompt.example.1": "Napraw TODO w bazie kodu",
"prompt.example.2": "Jaki jest stos technologiczny tego projektu?",
"prompt.example.3": "Napraw zepsute testy",
@@ -315,6 +327,75 @@ export const dict = {
"dialog.server.menu.delete": "Usuń",
"dialog.server.current": "Obecny serwer",
"dialog.server.status.default": "Domyślny",
"wsl.server.add": "Dodaj serwer WSL",
"wsl.server.addShort": "Dodaj WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "Serwer WSL",
"wsl.server.retryStart": "Ponów uruchamianie",
"wsl.server.updating": "Aktualizowanie...",
"wsl.onboarding.step.distro": "Wybierz dystrybucję",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Sprawdzanie WSL...",
"wsl.onboarding.restartRequired": "Aby dokończyć instalację WSL, uruchom ponownie system Windows.",
"wsl.onboarding.ready": "WSL jest gotowy.",
"wsl.onboarding.required": "Aby kontynuować, wymagany jest WSL.",
"wsl.onboarding.checkingDistros": "Sprawdzanie dystrybucji...",
"wsl.onboarding.installingDistro": "Instalowanie {{distro}}...",
"wsl.onboarding.checkingDistro": "Sprawdzanie {{distro}}...",
"wsl.onboarding.listingDistros": "Pobieranie listy dystrybucji...",
"wsl.onboarding.distroReady": "Dystrybucja {{distro}} jest gotowa.",
"wsl.onboarding.distroNotInstalled": "Dystrybucja {{distro}} nie jest jeszcze zainstalowana.",
"wsl.onboarding.openDistroOnce": "Uruchom raz {{distro}}, aby dokończyć konfigurację.",
"wsl.onboarding.finishingDistro": "Kończenie konfiguracji {{distro}}.",
"wsl.onboarding.pickDistro": "Wybierz dystrybucję lub zainstaluj jedną z poniższych.",
"wsl.onboarding.checkingOpencode": "Sprawdzanie OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Sprawdzanie OpenCode w dystrybucji {{distro}}...",
"wsl.onboarding.updatingOpencode": "Aktualizowanie OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Aktualizowanie OpenCode w dystrybucji {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Zaktualizuj OpenCode w dystrybucji {{distro}}.",
"wsl.onboarding.updateOpencode": "Zaktualizuj OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode jest gotowy w dystrybucji {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode jest gotowy.",
"wsl.onboarding.installOpencodeIn": "Zainstaluj OpenCode w dystrybucji {{distro}}.",
"wsl.onboarding.installOpencode": "Zainstaluj OpenCode",
"wsl.onboarding.chooseDistroFirst": "Najpierw wybierz dystrybucję.",
"wsl.onboarding.loadFailed": "Nie udało się załadować stanu WSL.",
"wsl.onboarding.loading": "Ładowanie...",
"wsl.onboarding.installedDistros": "Zainstalowane dystrybucje",
"wsl.onboarding.checkAgain": "Sprawdź ponownie",
"wsl.onboarding.distroStatus.ready": "Gotowa",
"wsl.onboarding.distroStatus.checking": "Sprawdzanie...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nie jest zainstalowany",
"wsl.onboarding.distroStatus.missingTools": "Brak bash i curl",
"wsl.onboarding.distroStatus.unsupported": "Nieobsługiwana · Użyj WSL 2",
"wsl.onboarding.needAnotherDistro": "Potrzebujesz innej dystrybucji?",
"wsl.onboarding.needAnotherDistroHint": "Zainstaluj dystrybucję systemu Linux z katalogu WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL nie jest zainstalowany",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Podsystem Windows dla systemu Linux) jest wymagany, zanim OpenCode będzie mógł dodać serwer WSL",
"wsl.onboarding.wslUnavailable.title": "WSL jest niedostępny",
"wsl.onboarding.wslUnavailable.description": "OpenCode nie mógł zweryfikować WSL na tym komputerze.",
"wsl.onboarding.installWsl": "Zainstaluj WSL",
"wsl.onboarding.windowsRestartRequired":
"Uruchom ponownie system Windows, aby dokończyć instalację WSL, a następnie ponownie otwórz OpenCode.",
"wsl.onboarding.next": "Dalej",
"wsl.onboarding.refresh": "Odśwież",
"wsl.onboarding.allDistrosAdded": "Wszystkie zainstalowane dystrybucje zostały już dodane.",
"wsl.onboarding.noDistros": "Nie wykryto jeszcze żadnych dystrybucji.",
"wsl.onboarding.install": "Zainstaluj",
"wsl.onboarding.installing": "Instalowanie...",
"wsl.onboarding.installDistro": "Zainstaluj dystrybucję",
"wsl.onboarding.searchDistros": "Szukaj dystrybucji",
"wsl.onboarding.wsl2Required": "Wymagany jest WSL 2.",
"wsl.onboarding.toolsRequired": "Ta dystrybucja wymaga bash i curl.",
"wsl.onboarding.openTerminal": "Otwórz terminal",
"wsl.onboarding.path": "Ścieżka: {{path}}",
"wsl.onboarding.notFound": "nie znaleziono",
"wsl.onboarding.version": "Wersja: {{version}}",
"wsl.onboarding.unknown": "nieznana",
"wsl.onboarding.desktopVersion": "aplikacja komputerowa {{version}}",
"wsl.onboarding.versionMismatch": "Zainstalowana wersja nie jest zgodna z wersją aplikacji komputerowej.",
"wsl.onboarding.adding": "Dodawanie...",
"dialog.project.edit.title": "Edytuj projekt",
"dialog.project.edit.name": "Nazwa",
"dialog.project.edit.icon": "Ikona",
@@ -324,7 +405,8 @@ export const dict = {
"dialog.project.edit.color": "Kolor",
"dialog.project.edit.color.select": "Wybierz kolor {{color}}",
"dialog.project.edit.worktree.startup": "Skrypt uruchamiania przestrzeni roboczej",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.description":
"Uruchamiany po utworzeniu nowej przestrzeni roboczej (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "np. bun install",
"context.breakdown.title": "Podział kontekstu",
"context.breakdown.note": 'Przybliżony podział tokenów wejściowych. "Inne" obejmuje definicje narzędzi i narzut.',
@@ -371,6 +453,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Język",
@@ -407,10 +490,12 @@ export const dict = {
"toast.update.action.notYet": "Jeszcze nie",
"error.page.title": "Coś poszło nie tak",
"error.page.description": "Wystąpił błąd podczas ładowania aplikacji.",
"error.page.description.localServerStartup": "Wystąpił błąd podczas uruchamiania serwera lokalnego.",
"error.page.details.label": "Szczegóły błędu",
"error.page.action.restart": "Restartuj",
"error.page.action.report": "Zgłoś błąd",
"error.page.action.reported": "Błąd zgłoszony",
"error.page.action.exportLogs": "Eksportuj dzienniki",
"error.page.action.checking": "Sprawdzanie...",
"error.page.action.checkUpdates": "Sprawdź aktualizacje",
"error.page.action.updateTo": "Zaktualizuj do {{version}}",
@@ -430,7 +515,8 @@ export const dict = {
"error.chain.didYouMean": "Czy miałeś na myśli: {{suggestions}}",
"error.chain.modelNotFound": "Model nie znaleziony: {{provider}}/{{model}}",
"error.chain.checkConfig": "Sprawdź swoją konfigurację (opencode.json) nazwy dostawców/modeli",
"error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenCode does not support MCP authentication yet.',
"error.chain.mcpFailed":
"Serwer MCP „{{name}}” nie uruchomił się. Uwaga: OpenCode nie obsługuje jeszcze uwierzytelniania MCP.",
"error.chain.providerAuthFailed": "Uwierzytelnianie dostawcy nie powiodło się ({{provider}}): {{message}}",
"error.chain.providerInitFailed":
'Nie udało się zainicjować dostawcy "{{provider}}". Sprawdź poświadczenia i konfigurację.',
@@ -452,10 +538,30 @@ export const dict = {
"home.recentProjects": "Ostatnie projekty",
"home.empty.title": "Brak ostatnich projektów",
"home.empty.description": "Zacznij od otwarcia lokalnego projektu",
"home.title": "Strona główna",
"home.projects": "Projekty",
"home.project.add": "Dodaj projekt",
"home.recentlyClosed": "Ostatnio zamknięte",
"home.server.collapse": "Zwiń projekty serwera",
"home.server.expand": "Rozwiń projekty serwera",
"home.sessions.search.placeholder": "Szukaj sesji",
"home.sessions.search.placeholder.scoped": "Szukaj sesji w {{scope}}",
"home.sessions.search.sessions": "Sesje",
"home.sessions.search.noResults": "Nie znaleziono sesji dla {{query}}",
"home.sessions.empty": "Jeszcze nic tu nie ma",
"home.sessions.empty.description": "Utwórz sesję, aby rozpocząć",
"home.sessions.group.today": "Dzisiaj",
"home.sessions.group.yesterday": "Wczoraj",
"home.sessions.group.older": "Starsze",
"session.tab.session": "Sesja",
"session.tab.review": "Przegląd",
"session.tab.context": "Kontekst",
"session.tab.unknown": "Nieznana sesja",
"session.panel.reviewAndFiles": "Przegląd i pliki",
"session.error.notFound": "Nie można znaleźć tej sesji",
"session.error.notFound.description": "Ta karta wskazuje sesję, która nie istnieje już na tym serwerze.",
"session.error.notFound.closeTab": "Zamknij kartę",
"session.error.serverConnection": "Nie można połączyć się z tym serwerem",
"session.review.filesChanged": "Zmieniono {{count}} plików",
"session.review.change.one": "Zmiana",
"session.review.change.other": "Zmiany",
@@ -464,6 +570,8 @@ export const dict = {
"session.review.noVcs": "Nie wykryto systemu kontroli wersji Git, zmiany nie są wyświetlane",
"session.review.noSnapshot": "Śledzenie migawek jest wyłączone w konfiguracji, więc zmiany w sesji są niedostępne",
"session.review.noChanges": "Brak zmian",
"session.review.noUncommittedChanges": "Brak jeszcze niezatwierdzonych zmian",
"session.review.noBranchChanges": "Brak jeszcze zmian w gałęzi",
"session.files.selectToOpen": "Wybierz plik do otwarcia",
"session.files.all": "Wszystkie pliki",
"session.files.empty": "Brak plików",
@@ -477,6 +585,10 @@ export const dict = {
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.question.minimize": "Zminimalizuj pytanie",
"session.question.restore": "Przywróć pytanie",
"session.question.pending.one": "{{count}} oczekujące pytanie",
"session.question.pending.other": "Pytania oczekujące: {{count}}",
"session.followupDock.summary.one": "{{count}} wiadomość w kolejce",
"session.followupDock.summary.other": "{{count}} wiadomości w kolejce",
"session.followupDock.sendNow": "Wyślij teraz",
@@ -489,9 +601,16 @@ export const dict = {
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
"session.revertDock.restore": "Przywróć wiadomość",
"session.new.title": "Zbuduj cokolwiek",
"session.new.project.new": "Nowy projekt",
"session.new.project.search": "Szukaj projektów",
"session.new.project.add": "Dodaj projekt",
"session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
"session.new.workspace.runIn": "Uruchom sesję w",
"session.new.workspace.triggerLocal": "Lokalnie",
"session.new.workspace.local": "Lokalne repozytorium",
"session.new.workspace.existing": "Przestrzeń robocza…",
"session.new.lastModified": "Ostatnio zmodyfikowano",
"session.header.search.placeholder": "Szukaj {{project}}",
"session.header.searchFiles": "Szukaj plików",
@@ -555,15 +674,18 @@ export const dict = {
"sidebar.project.recentSessions": "Ostatnie sesje",
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
"sidebar.project.clearNotifications": "Wyczyść powiadomienia",
"sidebar.empty.title": "Brak otwartych projektów",
"sidebar.empty.description": "Otwórz projekt, aby rozpocząć",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Pulpit",
"settings.section.server": "Serwer",
"settings.tab.general": "Ogólne",
"settings.tab.shortcuts": "Skróty",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.desktop.wsl.title": "Integracja z WSL",
"settings.desktop.wsl.description": "Uruchamiaj serwer OpenCode wewnątrz WSL w systemie Windows.",
"settings.general.section.appearance": "Wygląd",
"settings.general.section.advanced": "Zaawansowane",
"settings.general.section.notifications": "Powiadomienia systemowe",
"settings.general.section.updates": "Aktualizacje",
"settings.general.section.sounds": "Efekty dźwiękowe",
@@ -571,6 +693,11 @@ export const dict = {
"settings.general.section.display": "Ekran",
"settings.general.row.language.title": "Język",
"settings.general.row.language.description": "Zmień język wyświetlania dla OpenCode",
"settings.general.row.shell.title": "Powłoka terminala",
"settings.general.row.shell.description":
"Wybierz powłokę używaną w terminalu. Zgodne powłoki są również używane przez wywołania narzędzi agenta.",
"settings.general.row.shell.autoDefault": "Automatycznie (domyślna)",
"settings.general.row.shell.terminalOnly": "tylko terminal",
"settings.general.row.appearance.title": "Wygląd",
"settings.general.row.appearance.description": "Dostosuj wygląd OpenCode na swoim urządzeniu",
"settings.general.row.colorScheme.title": "Schemat kolorów",
@@ -580,14 +707,30 @@ export const dict = {
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka kodu",
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Czcionka terminala",
"settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu",
"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",
"settings.general.row.followup.option.steer": "Sterowanie",
"settings.general.row.showFileTree.title": "Drzewo plików",
"settings.general.row.showFileTree.description": "Pokaż panel drzewa plików w sesjach",
"settings.general.row.showNavigation.title": "Elementy nawigacji",
"settings.general.row.showNavigation.description":
"Pokaż przyciski Wstecz i Dalej na pasku tytułu aplikacji komputerowej",
"settings.general.row.showSearch.title": "Paleta poleceń",
"settings.general.row.showSearch.description": "Pokaż przycisk wyszukiwania i palety poleceń na pasku tytułu",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Pokaż przycisk terminala na pasku tytułu aplikacji komputerowej",
"settings.general.row.showStatus.title": "Status serwera",
"settings.general.row.showStatus.description": "Pokaż przycisk statusu serwera na pasku tytułu",
"settings.general.row.mobileTitlebarBottom.title": "Dolna nawigacja",
"settings.general.row.mobileTitlebarBottom.description":
"Umieść pasek tytułu i karty sesji u dołu ekranu na urządzeniach mobilnych",
"settings.general.row.showCustomAgents.title": "Agenci niestandardowi",
"settings.general.row.showCustomAgents.description": "Pokaż wybór agenta w edytorze wiadomości",
"settings.general.row.reasoningSummaries.title": "Pokaż podsumowania wnioskowania",
"settings.general.row.reasoningSummaries.description": "Wyświetlaj podsumowania wnioskowania modelu na osi czasu",
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
@@ -596,6 +739,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd",
"settings.general.row.newLayoutDesigns.description":
"Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji",
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
"settings.general.row.pinchZoom.description":
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip":
@@ -608,6 +757,8 @@ export const dict = {
"settings.updates.row.check.description": "Ręcznie sprawdź aktualizacje i zainstaluj, jeśli są dostępne",
"settings.updates.action.checkNow": "Sprawdź teraz",
"settings.updates.action.checking": "Sprawdzanie...",
"settings.updates.action.downloading": "Pobieranie...",
"settings.updates.action.installing": "Instalowanie...",
"settings.updates.toast.latest.title": "Masz najnowszą wersję",
"settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.",
"sound.option.none": "Brak",
+151 -2
View File
@@ -23,6 +23,9 @@ export const dict = {
"command.sidebar.toggle": "Переключить боковую панель",
"command.project.open": "Открыть проект",
"command.project.previous": "Предыдущий проект",
"command.project.next": "Следующий проект",
"command.project.index": "Переключиться на проект {{index}}",
"command.provider.connect": "Подключить провайдера",
"command.server.switch": "Переключить сервер",
"command.settings.open": "Открыть настройки",
@@ -45,6 +48,7 @@ export const dict = {
"command.session.new": "Новая сессия",
"command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.tab.reopenClosed": "Повторно открыть закрытую вкладку",
"command.context.addSelection": "Добавить выделение в контекст",
"command.context.addSelection.description": "Добавить выбранные строки из текущего файла",
"command.input.focus": "Фокус на поле ввода",
@@ -208,6 +212,11 @@ export const dict = {
"model.input.video": "видео",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Разрешено: {{inputs}}",
"model.tooltip.context.label": "Контекст",
"model.tooltip.inputs": "Входные данные",
"model.tooltip.model": "Модель",
"model.tooltip.provider": "Провайдер",
"model.tooltip.reasoning": "Рассуждение",
"model.tooltip.reasoning.allowed": "Разрешает рассуждение",
"model.tooltip.reasoning.none": "Без рассуждения",
"model.tooltip.context": "Лимит контекста {{limit}}",
@@ -218,6 +227,7 @@ export const dict = {
"common.loading": "Загрузка",
"common.loading.ellipsis": "...",
"common.cancel": "Отмена",
"common.clear": "Очистить",
"common.connect": "Подключить",
"common.disconnect": "Отключить",
"common.continue": "Отправить",
@@ -235,6 +245,8 @@ export const dict = {
"prompt.mode.shell": "Оболочка",
"prompt.mode.normal": "Промпт",
"prompt.mode.shell.exit": "esc для выхода",
"session.child.promptDisabled": "В сессиях субагентов нельзя отправлять запросы.",
"session.child.backToParent": "Вернуться к основной сессии.",
"prompt.example.1": "Исправить TODO в коде",
"prompt.example.2": "Какой технологический стек этого проекта?",
@@ -341,6 +353,75 @@ export const dict = {
"dialog.server.menu.delete": "Удалить",
"dialog.server.current": "Текущий сервер",
"dialog.server.status.default": "По умолч.",
"wsl.server.add": "Добавить сервер WSL",
"wsl.server.addShort": "Добавить WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "Сервер WSL",
"wsl.server.retryStart": "Повторить запуск",
"wsl.server.updating": "Обновление...",
"wsl.onboarding.step.distro": "Выбрать дистрибутив",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Проверка WSL...",
"wsl.onboarding.restartRequired": "Чтобы завершить установку WSL, перезапустите Windows.",
"wsl.onboarding.ready": "WSL готова к работе.",
"wsl.onboarding.required": "Для продолжения требуется WSL.",
"wsl.onboarding.checkingDistros": "Проверка дистрибутивов...",
"wsl.onboarding.installingDistro": "Установка {{distro}}...",
"wsl.onboarding.checkingDistro": "Проверка {{distro}}...",
"wsl.onboarding.listingDistros": "Получение списка дистрибутивов...",
"wsl.onboarding.distroReady": "Дистрибутив {{distro}} готов к работе.",
"wsl.onboarding.distroNotInstalled": "Дистрибутив {{distro}} пока не установлен.",
"wsl.onboarding.openDistroOnce": "Откройте {{distro}} один раз, чтобы завершить настройку.",
"wsl.onboarding.finishingDistro": "Завершение настройки {{distro}}.",
"wsl.onboarding.pickDistro": "Выберите дистрибутив или установите его ниже.",
"wsl.onboarding.checkingOpencode": "Проверка OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Проверка OpenCode в {{distro}}...",
"wsl.onboarding.updatingOpencode": "Обновление OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Обновление OpenCode в {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Обновите OpenCode в {{distro}}.",
"wsl.onboarding.updateOpencode": "Обновить OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode в {{distro}} готов к работе.",
"wsl.onboarding.opencodeReady": "OpenCode готов к работе.",
"wsl.onboarding.installOpencodeIn": "Установите OpenCode в {{distro}}.",
"wsl.onboarding.installOpencode": "Установить OpenCode",
"wsl.onboarding.chooseDistroFirst": "Сначала выберите дистрибутив.",
"wsl.onboarding.loadFailed": "Не удалось загрузить состояние WSL.",
"wsl.onboarding.loading": "Загрузка...",
"wsl.onboarding.installedDistros": "Установленные дистрибутивы",
"wsl.onboarding.checkAgain": "Проверить снова",
"wsl.onboarding.distroStatus.ready": "Готов",
"wsl.onboarding.distroStatus.checking": "Проверка...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode не установлен",
"wsl.onboarding.distroStatus.missingTools": "Отсутствуют bash и curl",
"wsl.onboarding.distroStatus.unsupported": "Не поддерживается · Используйте WSL 2",
"wsl.onboarding.needAnotherDistro": "Нужен другой дистрибутив?",
"wsl.onboarding.needAnotherDistroHint": "Установите дистрибутив Linux из каталога WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL не установлена",
"wsl.onboarding.wslNotInstalled.description":
"Для добавления сервера WSL в OpenCode требуется WSL (Windows Subsystem for Linux)",
"wsl.onboarding.wslUnavailable.title": "WSL недоступна",
"wsl.onboarding.wslUnavailable.description": "OpenCode не смог проверить WSL на этом компьютере.",
"wsl.onboarding.installWsl": "Установить WSL",
"wsl.onboarding.windowsRestartRequired":
"Перезапустите Windows, чтобы завершить установку WSL, затем снова откройте OpenCode.",
"wsl.onboarding.next": "Далее",
"wsl.onboarding.refresh": "Обновить",
"wsl.onboarding.allDistrosAdded": "Все установленные дистрибутивы уже добавлены.",
"wsl.onboarding.noDistros": "Дистрибутивы пока не обнаружены.",
"wsl.onboarding.install": "Установить",
"wsl.onboarding.installing": "Установка...",
"wsl.onboarding.installDistro": "Установить дистрибутив",
"wsl.onboarding.searchDistros": "Поиск дистрибутивов",
"wsl.onboarding.wsl2Required": "Требуется WSL 2.",
"wsl.onboarding.toolsRequired": "Для этого дистрибутива нужны bash и curl.",
"wsl.onboarding.openTerminal": "Открыть терминал",
"wsl.onboarding.path": "Путь: {{path}}",
"wsl.onboarding.notFound": "не найден",
"wsl.onboarding.version": "Версия: {{version}}",
"wsl.onboarding.unknown": "неизвестно",
"wsl.onboarding.desktopVersion": "приложение {{version}}",
"wsl.onboarding.versionMismatch": "Установленная версия не соответствует версии приложения.",
"wsl.onboarding.adding": "Добавление...",
"dialog.project.edit.title": "Редактировать проект",
"dialog.project.edit.name": "Название",
@@ -405,6 +486,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -452,10 +534,12 @@ export const dict = {
"error.page.title": "Что-то пошло не так",
"error.page.description": "Произошла ошибка при загрузке приложения.",
"error.page.description.localServerStartup": "Произошла ошибка при запуске локального сервера.",
"error.page.details.label": "Детали ошибки",
"error.page.action.restart": "Перезапустить",
"error.page.action.report": "Сообщить об ошибке",
"error.page.action.reported": "Об ошибке сообщено",
"error.page.action.exportLogs": "Экспортировать журналы",
"error.page.action.checking": "Проверка...",
"error.page.action.checkUpdates": "Проверить обновления",
"error.page.action.updateTo": "Обновить до {{version}}",
@@ -505,11 +589,31 @@ export const dict = {
"home.recentProjects": "Недавние проекты",
"home.empty.title": "Нет недавних проектов",
"home.empty.description": "Начните с открытия локального проекта",
"home.title": "Главная",
"home.projects": "Проекты",
"home.project.add": "Добавить проект",
"home.recentlyClosed": "Недавно закрытые",
"home.server.collapse": "Свернуть проекты сервера",
"home.server.expand": "Развернуть проекты сервера",
"home.sessions.search.placeholder": "Поиск сессий",
"home.sessions.search.placeholder.scoped": "Поиск сессий в {{scope}}",
"home.sessions.search.sessions": "Сессии",
"home.sessions.search.noResults": "Сессии по запросу {{query}} не найдены",
"home.sessions.empty": "Здесь пока ничего нет",
"home.sessions.empty.description": "Создайте сессию, чтобы начать",
"home.sessions.group.today": "Сегодня",
"home.sessions.group.yesterday": "Вчера",
"home.sessions.group.older": "Ранее",
"session.tab.session": "Сессия",
"session.tab.review": "Обзор",
"session.tab.context": "Контекст",
"session.tab.unknown": "Неизвестная сессия",
"session.panel.reviewAndFiles": "Обзор и файлы",
"session.error.notFound": "Эта сессия не найдена",
"session.error.notFound.description": "Эта вкладка указывает на сессию, которой больше нет на этом сервере.",
"session.error.notFound.closeTab": "Закрыть вкладку",
"session.error.serverConnection": "Не удаётся подключиться к этому серверу",
"session.review.filesChanged": "{{count}} файлов изменено",
"session.review.change.one": "Изменение",
"session.review.change.other": "Изменения",
@@ -518,6 +622,8 @@ export const dict = {
"session.review.noVcs": "Система контроля версий Git не обнаружена, изменения не отображаются",
"session.review.noSnapshot": "Отслеживание снимков отключено в настройках, поэтому изменения сессии недоступны",
"session.review.noChanges": "Нет изменений",
"session.review.noUncommittedChanges": "Пока нет незафиксированных изменений",
"session.review.noBranchChanges": "Пока нет изменений в ветке",
"session.files.selectToOpen": "Выберите файл, чтобы открыть",
"session.files.all": "Все файлы",
"session.files.empty": "Нет файлов",
@@ -532,6 +638,10 @@ export const dict = {
"session.todo.title": "Задачи",
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.question.minimize": "Свернуть вопрос",
"session.question.restore": "Восстановить вопрос",
"session.question.pending.one": "{{count}} вопрос без ответа",
"session.question.pending.other": "Вопросы без ответа: {{count}}",
"session.followupDock.summary.one": "{{count}} сообщение в очереди",
"session.followupDock.summary.other": "{{count}} сообщений в очереди",
"session.followupDock.sendNow": "Отправить сейчас",
@@ -545,9 +655,16 @@ export const dict = {
"session.revertDock.restore": "Восстановить сообщение",
"session.new.title": "Создавайте что угодно",
"session.new.project.new": "Новый проект",
"session.new.project.search": "Поиск проектов",
"session.new.project.add": "Добавить проект",
"session.new.worktree.main": "Основная ветка",
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
"session.new.worktree.create": "Создать новый worktree",
"session.new.workspace.runIn": "Запустить сессию в",
"session.new.workspace.triggerLocal": "Локально",
"session.new.workspace.local": "Локальный репозиторий",
"session.new.workspace.existing": "Рабочее пространство…",
"session.new.lastModified": "Последнее изменение",
"session.header.search.placeholder": "Поиск {{project}}",
@@ -619,6 +736,8 @@ export const dict = {
"sidebar.project.recentSessions": "Недавние сессии",
"sidebar.project.viewAllSessions": "Посмотреть все сессии",
"sidebar.project.clearNotifications": "Очистить уведомления",
"sidebar.empty.title": "Нет открытых проектов",
"sidebar.empty.description": "Откройте проект, чтобы начать",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Приложение",
@@ -630,6 +749,7 @@ export const dict = {
"settings.desktop.wsl.description": "Запускать сервер OpenCode внутри WSL на Windows.",
"settings.general.section.appearance": "Внешний вид",
"settings.general.section.advanced": "Дополнительно",
"settings.general.section.notifications": "Системные уведомления",
"settings.general.section.updates": "Обновления",
"settings.general.section.sounds": "Звуковые эффекты",
@@ -638,6 +758,11 @@ export const dict = {
"settings.general.row.language.title": "Язык",
"settings.general.row.language.description": "Изменить язык отображения OpenCode",
"settings.general.row.shell.title": "Оболочка терминала",
"settings.general.row.shell.description":
"Выберите оболочку для терминала. Совместимые оболочки также используются агентом при вызове инструментов.",
"settings.general.row.shell.autoDefault": "Авто (по умолчанию)",
"settings.general.row.shell.terminalOnly": "только терминал",
"settings.general.row.appearance.title": "Внешний вид",
"settings.general.row.appearance.description": "Настройте как OpenCode выглядит на вашем устройстве",
"settings.general.row.colorScheme.title": "Цветовая схема",
@@ -646,8 +771,8 @@ export const dict = {
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
"settings.general.row.font.title": "Шрифт кода",
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Шрифт терминала",
"settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
@@ -655,6 +780,22 @@ export const dict = {
"Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
"settings.general.row.followup.option.queue": "Очередь",
"settings.general.row.followup.option.steer": "Направлять",
"settings.general.row.showFileTree.title": "Дерево файлов",
"settings.general.row.showFileTree.description": "Показывать панель дерева файлов в сессиях",
"settings.general.row.showNavigation.title": "Элементы навигации",
"settings.general.row.showNavigation.description":
"Показывать кнопки «Назад» и «Вперёд» в строке заголовка приложения",
"settings.general.row.showSearch.title": "Палитра команд",
"settings.general.row.showSearch.description": "Показывать кнопку поиска и палитры команд в строке заголовка",
"settings.general.row.showTerminal.title": "Терминал",
"settings.general.row.showTerminal.description": "Показывать кнопку терминала в строке заголовка приложения",
"settings.general.row.showStatus.title": "Состояние сервера",
"settings.general.row.showStatus.description": "Показывать кнопку состояния сервера в строке заголовка",
"settings.general.row.mobileTitlebarBottom.title": "Нижняя навигация",
"settings.general.row.mobileTitlebarBottom.description":
"На мобильных устройствах размещать строку заголовка и вкладки сессии внизу экрана",
"settings.general.row.showCustomAgents.title": "Пользовательские агенты",
"settings.general.row.showCustomAgents.description": "Показывать выбор агента в редакторе запросов",
"settings.general.row.reasoningSummaries.title": "Показывать сводки рассуждений",
"settings.general.row.reasoningSummaries.description": "Отображать сводки рассуждений модели в ленте",
@@ -664,6 +805,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление",
"settings.general.row.newLayoutDesigns.description":
"Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии",
"settings.general.row.pinchZoom.title": "Масштабирование щипком",
"settings.general.row.pinchZoom.description":
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",
"settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip":
@@ -678,6 +825,8 @@ export const dict = {
"settings.updates.row.check.description": "Проверить обновления вручную и установить, если доступны",
"settings.updates.action.checkNow": "Проверить сейчас",
"settings.updates.action.checking": "Проверка...",
"settings.updates.action.downloading": "Загрузка...",
"settings.updates.action.installing": "Установка...",
"settings.updates.toast.latest.title": "У вас последняя версия",
"settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.",
"sound.option.none": "Нет",
+154 -7
View File
@@ -23,6 +23,9 @@ export const dict = {
"command.sidebar.toggle": "สลับแถบข้าง",
"command.project.open": "เปิดโปรเจกต์",
"command.project.previous": "โปรเจกต์ก่อนหน้า",
"command.project.next": "โปรเจกต์ถัดไป",
"command.project.index": "สลับไปยังโปรเจกต์ {{index}}",
"command.provider.connect": "เชื่อมต่อผู้ให้บริการ",
"command.server.switch": "สลับเซิร์ฟเวอร์",
"command.settings.open": "เปิดการตั้งค่า",
@@ -32,7 +35,7 @@ export const dict = {
"command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป",
"command.session.archive": "จัดเก็บเซสชัน",
"command.palette": "คำสั่งค้นหา",
"command.palette": "ชุดคำสั่ง",
"command.theme.cycle": "เปลี่ยนธีม",
"command.theme.set": "ใช้ธีม: {{theme}}",
@@ -45,6 +48,7 @@ export const dict = {
"command.session.new": "เซสชันใหม่",
"command.file.open": "เปิดไฟล์",
"command.tab.close": "ปิดแท็บ",
"command.tab.reopenClosed": "เปิดแท็บที่ปิดไปอีกครั้ง",
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
"command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
"command.input.focus": "โฟกัสช่องป้อนข้อมูล",
@@ -163,7 +167,7 @@ export const dict = {
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "ตัวอักษรพิมพ์เล็ก ตัวเลข ยัติภังค์ หรือขีดล่าง",
"provider.custom.field.name.label": "ชื่อที่แสดง",
"provider.custom.field.name.placeholder": "My AI Provider",
"provider.custom.field.name.placeholder": "ผู้ให้บริการ AI ของฉัน",
"provider.custom.field.baseURL.label": "URL พื้นฐาน",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "คีย์ API",
@@ -208,6 +212,11 @@ export const dict = {
"model.input.video": "วิดีโอ",
"model.input.pdf": "PDF",
"model.tooltip.allows": "อนุญาต: {{inputs}}",
"model.tooltip.context.label": "บริบท",
"model.tooltip.inputs": "ข้อมูลนำเข้า",
"model.tooltip.model": "โมเดล",
"model.tooltip.provider": "ผู้ให้บริการ",
"model.tooltip.reasoning": "การใช้เหตุผล",
"model.tooltip.reasoning.allowed": "อนุญาตการใช้เหตุผล",
"model.tooltip.reasoning.none": "ไม่มีการใช้เหตุผล",
"model.tooltip.context": "ขีดจำกัดบริบท {{limit}}",
@@ -218,6 +227,7 @@ export const dict = {
"common.loading": "กำลังโหลด",
"common.loading.ellipsis": "...",
"common.cancel": "ยกเลิก",
"common.clear": "ล้าง",
"common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ",
"common.continue": "ส่ง",
@@ -235,6 +245,8 @@ export const dict = {
"prompt.mode.shell": "เชลล์",
"prompt.mode.normal": "พรอมต์",
"prompt.mode.shell.exit": "กด esc เพื่อออก",
"session.child.promptDisabled": "ไม่สามารถส่งพรอมต์ในเซสชันเอเจนต์ย่อยได้.",
"session.child.backToParent": "กลับไปยังเซสชันหลัก.",
"prompt.example.1": "แก้ไข TODO ในโค้ดเบส",
"prompt.example.2": "เทคโนโลยีของโปรเจกต์นี้คืออะไร?",
@@ -341,6 +353,75 @@ export const dict = {
"dialog.server.menu.delete": "ลบ",
"dialog.server.current": "เซิร์ฟเวอร์ปัจจุบัน",
"dialog.server.status.default": "ค่าเริ่มต้น",
"wsl.server.add": "เพิ่มเซิร์ฟเวอร์ WSL",
"wsl.server.addShort": "เพิ่ม WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "เซิร์ฟเวอร์ WSL",
"wsl.server.retryStart": "ลองเริ่มอีกครั้ง",
"wsl.server.updating": "กำลังอัปเดต...",
"wsl.onboarding.step.distro": "เลือกดิสโทร",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "กำลังตรวจสอบ WSL...",
"wsl.onboarding.restartRequired": "ต้องรีสตาร์ท Windows เพื่อให้การติดตั้ง WSL เสร็จสมบูรณ์.",
"wsl.onboarding.ready": "WSL พร้อมใช้งานแล้ว.",
"wsl.onboarding.required": "ต้องมี WSL เพื่อดำเนินการต่อ.",
"wsl.onboarding.checkingDistros": "กำลังตรวจสอบดิสโทร...",
"wsl.onboarding.installingDistro": "กำลังติดตั้ง {{distro}}...",
"wsl.onboarding.checkingDistro": "กำลังตรวจสอบ {{distro}}...",
"wsl.onboarding.listingDistros": "กำลังแสดงรายการดิสโทร...",
"wsl.onboarding.distroReady": "{{distro}} พร้อมใช้งานแล้ว.",
"wsl.onboarding.distroNotInstalled": "ยังไม่ได้ติดตั้ง {{distro}}.",
"wsl.onboarding.openDistroOnce": "เปิด {{distro}} หนึ่งครั้งเพื่อให้การตั้งค่าเสร็จสมบูรณ์.",
"wsl.onboarding.finishingDistro": "กำลังตั้งค่า {{distro}} ให้เสร็จสมบูรณ์.",
"wsl.onboarding.pickDistro": "เลือกดิสโทรหรือติดตั้งจากรายการด้านล่าง.",
"wsl.onboarding.checkingOpencode": "กำลังตรวจสอบ OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "กำลังตรวจสอบ OpenCode ใน {{distro}}...",
"wsl.onboarding.updatingOpencode": "กำลังอัปเดต OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "กำลังอัปเดต OpenCode ใน {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "อัปเดต OpenCode ใน {{distro}}.",
"wsl.onboarding.updateOpencode": "อัปเดต OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode พร้อมใช้งานใน {{distro}} แล้ว.",
"wsl.onboarding.opencodeReady": "OpenCode พร้อมใช้งานแล้ว.",
"wsl.onboarding.installOpencodeIn": "ติดตั้ง OpenCode ใน {{distro}}.",
"wsl.onboarding.installOpencode": "ติดตั้ง OpenCode",
"wsl.onboarding.chooseDistroFirst": "เลือกดิสโทรก่อน.",
"wsl.onboarding.loadFailed": "ไม่สามารถโหลดสถานะ WSL ได้.",
"wsl.onboarding.loading": "กำลังโหลด...",
"wsl.onboarding.installedDistros": "ดิสโทรที่ติดตั้งแล้ว",
"wsl.onboarding.checkAgain": "ตรวจสอบอีกครั้ง",
"wsl.onboarding.distroStatus.ready": "พร้อมใช้งาน",
"wsl.onboarding.distroStatus.checking": "กำลังตรวจสอบ...",
"wsl.onboarding.distroStatus.opencodeMissing": "ยังไม่ได้ติดตั้ง OpenCode",
"wsl.onboarding.distroStatus.missingTools": "ไม่มี bash, curl",
"wsl.onboarding.distroStatus.unsupported": "ไม่รองรับ · ใช้ WSL 2",
"wsl.onboarding.needAnotherDistro": "ต้องการดิสโทรอื่น?",
"wsl.onboarding.needAnotherDistroHint": "ติดตั้งดิสทริบิวชัน Linux จากแค็ตตาล็อก WSL",
"wsl.onboarding.wslNotInstalled.title": "ยังไม่ได้ติดตั้ง WSL",
"wsl.onboarding.wslNotInstalled.description":
"ต้องมี WSL (Windows Subsystem for Linux) ก่อนที่ OpenCode จะเพิ่มเซิร์ฟเวอร์ WSL ได้",
"wsl.onboarding.wslUnavailable.title": "WSL ไม่พร้อมใช้งาน",
"wsl.onboarding.wslUnavailable.description": "OpenCode ไม่สามารถตรวจสอบ WSL บนเครื่องนี้ได้.",
"wsl.onboarding.installWsl": "ติดตั้ง WSL",
"wsl.onboarding.windowsRestartRequired":
"รีสตาร์ท Windows เพื่อให้การติดตั้ง WSL เสร็จสมบูรณ์ แล้วเปิด OpenCode อีกครั้ง.",
"wsl.onboarding.next": "ถัดไป",
"wsl.onboarding.refresh": "รีเฟรช",
"wsl.onboarding.allDistrosAdded": "เพิ่มดิสโทรที่ติดตั้งไว้ทั้งหมดแล้ว.",
"wsl.onboarding.noDistros": "ยังไม่พบดิสโทร.",
"wsl.onboarding.install": "ติดตั้ง",
"wsl.onboarding.installing": "กำลังติดตั้ง...",
"wsl.onboarding.installDistro": "ติดตั้งดิสโทร",
"wsl.onboarding.searchDistros": "ค้นหาดิสโทร",
"wsl.onboarding.wsl2Required": "ต้องใช้ WSL 2.",
"wsl.onboarding.toolsRequired": "ดิสโทรนี้ต้องมี bash และ curl.",
"wsl.onboarding.openTerminal": "เปิดเทอร์มินัล",
"wsl.onboarding.path": "เส้นทาง: {{path}}",
"wsl.onboarding.notFound": "ไม่พบ",
"wsl.onboarding.version": "เวอร์ชัน: {{version}}",
"wsl.onboarding.unknown": "ไม่ทราบ",
"wsl.onboarding.desktopVersion": "เดสก์ท็อป {{version}}",
"wsl.onboarding.versionMismatch": "เวอร์ชันที่ติดตั้งไม่ตรงกับเวอร์ชันของแอปเดสก์ท็อป.",
"wsl.onboarding.adding": "กำลังเพิ่ม...",
"dialog.project.edit.title": "แก้ไขโปรเจกต์",
"dialog.project.edit.name": "ชื่อ",
@@ -403,6 +484,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -451,10 +533,12 @@ export const dict = {
"error.page.title": "เกิดข้อผิดพลาด",
"error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน",
"error.page.description.localServerStartup": "เกิดข้อผิดพลาดขณะเริ่มเซิร์ฟเวอร์ในเครื่อง.",
"error.page.details.label": "รายละเอียดข้อผิดพลาด",
"error.page.action.restart": "รีสตาร์ท",
"error.page.action.report": "รายงานข้อผิดพลาด",
"error.page.action.reported": "รายงานข้อผิดพลาดแล้ว",
"error.page.action.exportLogs": "ส่งออกบันทึก",
"error.page.action.checking": "กำลังตรวจสอบ...",
"error.page.action.checkUpdates": "ตรวจสอบการอัปเดต",
"error.page.action.updateTo": "อัปเดตเป็น {{version}}",
@@ -500,11 +584,31 @@ export const dict = {
"home.recentProjects": "โปรเจกต์ล่าสุด",
"home.empty.title": "ไม่มีโปรเจกต์ล่าสุด",
"home.empty.description": "เริ่มต้นโดยเปิดโปรเจกต์ในเครื่อง",
"home.title": "หน้าหลัก",
"home.projects": "โปรเจกต์",
"home.project.add": "เพิ่มโปรเจกต์",
"home.recentlyClosed": "ที่ปิดไปล่าสุด",
"home.server.collapse": "ย่อโปรเจกต์ของเซิร์ฟเวอร์",
"home.server.expand": "ขยายโปรเจกต์ของเซิร์ฟเวอร์",
"home.sessions.search.placeholder": "ค้นหาเซสชัน",
"home.sessions.search.placeholder.scoped": "ค้นหาเซสชันใน {{scope}}",
"home.sessions.search.sessions": "เซสชัน",
"home.sessions.search.noResults": "ไม่พบเซสชันสำหรับ {{query}}",
"home.sessions.empty": "ยังไม่มีอะไรที่นี่",
"home.sessions.empty.description": "สร้างเซสชันเพื่อเริ่มต้น",
"home.sessions.group.today": "วันนี้",
"home.sessions.group.yesterday": "เมื่อวาน",
"home.sessions.group.older": "ก่อนหน้านี้",
"session.tab.session": "เซสชัน",
"session.tab.review": "ตรวจสอบ",
"session.tab.context": "บริบท",
"session.tab.unknown": "เซสชันที่ไม่รู้จัก",
"session.panel.reviewAndFiles": "ตรวจสอบและไฟล์",
"session.error.notFound": "ไม่พบเซสชันนี้",
"session.error.notFound.description": "แท็บนี้อ้างถึงเซสชันที่ไม่มีอยู่บนเซิร์ฟเวอร์นี้แล้ว.",
"session.error.notFound.closeTab": "ปิดแท็บ",
"session.error.serverConnection": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์นี้",
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
"session.review.change.one": "การเปลี่ยนแปลง",
"session.review.change.other": "การเปลี่ยนแปลง",
@@ -513,6 +617,8 @@ export const dict = {
"session.review.noVcs": "ไม่ตรวจพบระบบควบคุมเวอร์ชัน Git การเปลี่ยนแปลงจะไม่แสดง",
"session.review.noSnapshot": "การติดตามสแนปชอตถูกปิดใช้งานในการกำหนดค่า ดังนั้นการเปลี่ยนแปลงเซสชันจึงไม่พร้อมใช้งาน",
"session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
"session.review.noUncommittedChanges": "ยังไม่มีการเปลี่ยนแปลงที่รอคอมมิต",
"session.review.noBranchChanges": "ยังไม่มีการเปลี่ยนแปลงในสาขา",
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
"session.files.empty": "ไม่มีไฟล์",
@@ -529,6 +635,10 @@ export const dict = {
"session.todo.title": "สิ่งที่ต้องทำ",
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.question.minimize": "ย่อคำถาม",
"session.question.restore": "คืนค่าคำถาม",
"session.question.pending.one": "คำถามที่รอดำเนินการ {{count}} ข้อ",
"session.question.pending.other": "คำถามที่รอดำเนินการ {{count}} ข้อ",
"session.followupDock.summary.one": "{{count}} ข้อความในคิว",
"session.followupDock.summary.other": "{{count}} ข้อความในคิว",
"session.followupDock.sendNow": "ส่งทันที",
@@ -542,9 +652,16 @@ export const dict = {
"session.revertDock.restore": "กู้คืนข้อความ",
"session.new.title": "สร้างอะไรก็ได้",
"session.new.project.new": "โปรเจกต์ใหม่",
"session.new.project.search": "ค้นหาโปรเจกต์",
"session.new.project.add": "เพิ่มโปรเจกต์",
"session.new.worktree.main": "สาขาหลัก",
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
"session.new.worktree.create": "สร้าง worktree ใหม่",
"session.new.workspace.runIn": "เรียกใช้เซสชันใน",
"session.new.workspace.triggerLocal": "ในเครื่อง",
"session.new.workspace.local": "รีโพซิทอรีในเครื่อง",
"session.new.workspace.existing": "พื้นที่ทำงาน…",
"session.new.lastModified": "แก้ไขล่าสุด",
"session.header.search.placeholder": "ค้นหา {{project}}",
@@ -612,6 +729,8 @@ export const dict = {
"sidebar.project.recentSessions": "เซสชันล่าสุด",
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
"sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
"sidebar.empty.title": "ไม่มีโปรเจกต์ที่เปิดอยู่",
"sidebar.empty.description": "เปิดโปรเจกต์เพื่อเริ่มต้น",
"app.name.desktop": "OpenCode Desktop",
@@ -624,6 +743,7 @@ export const dict = {
"settings.desktop.wsl.description": "เรียกใช้เซิร์ฟเวอร์ OpenCode ภายใน WSL บน Windows",
"settings.general.section.appearance": "รูปลักษณ์",
"settings.general.section.advanced": "ขั้นสูง",
"settings.general.section.notifications": "การแจ้งเตือนระบบ",
"settings.general.section.updates": "การอัปเดต",
"settings.general.section.sounds": "เสียงเอฟเฟกต์",
@@ -632,6 +752,11 @@ export const dict = {
"settings.general.row.language.title": "ภาษา",
"settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ OpenCode",
"settings.general.row.shell.title": "เชลล์เทอร์มินัล",
"settings.general.row.shell.description":
"เลือกเชลล์ที่จะใช้กับเทอร์มินัล เชลล์ที่เข้ากันได้จะใช้สำหรับการเรียกเครื่องมือของเอเจนต์ด้วย",
"settings.general.row.shell.autoDefault": "อัตโนมัติ (ค่าเริ่มต้น)",
"settings.general.row.shell.terminalOnly": "เทอร์มินัลเท่านั้น",
"settings.general.row.appearance.title": "รูปลักษณ์",
"settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ OpenCode มีลักษณะบนอุปกรณ์ของคุณ",
"settings.general.row.colorScheme.title": "โทนสี",
@@ -640,14 +765,29 @@ export const dict = {
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
"settings.general.row.font.title": "ฟอนต์โค้ด",
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล",
"settings.general.row.terminalFont.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": "คิว",
"settings.general.row.followup.option.steer": "นำทาง",
"settings.general.row.showFileTree.title": "ต้นไม้ไฟล์",
"settings.general.row.showFileTree.description": "แสดงแผงต้นไม้ไฟล์ในเซสชัน",
"settings.general.row.showNavigation.title": "ตัวควบคุมการนำทาง",
"settings.general.row.showNavigation.description": "แสดงปุ่มย้อนกลับและไปข้างหน้าในแถบชื่อเรื่องเดสก์ท็อป",
"settings.general.row.showSearch.title": "ชุดคำสั่ง",
"settings.general.row.showSearch.description": "แสดงปุ่มค้นหาและชุดคำสั่งในแถบชื่อเรื่อง",
"settings.general.row.showTerminal.title": "เทอร์มินัล",
"settings.general.row.showTerminal.description": "แสดงปุ่มเทอร์มินัลในแถบชื่อเรื่องเดสก์ท็อป",
"settings.general.row.showStatus.title": "สถานะเซิร์ฟเวอร์",
"settings.general.row.showStatus.description": "แสดงปุ่มสถานะเซิร์ฟเวอร์ในแถบชื่อเรื่อง",
"settings.general.row.mobileTitlebarBottom.title": "การนำทางด้านล่าง",
"settings.general.row.mobileTitlebarBottom.description":
"วางแถบชื่อเรื่องและแท็บเซสชันไว้ด้านล่างของหน้าจอบนอุปกรณ์เคลื่อนที่",
"settings.general.row.showCustomAgents.title": "เอเจนต์ที่กำหนดเอง",
"settings.general.row.showCustomAgents.description": "แสดงตัวเลือกเอเจนต์ในช่องเขียนข้อความ",
"settings.general.row.reasoningSummaries.title": "แสดงสรุปการใช้เหตุผล",
"settings.general.row.reasoningSummaries.description": "แสดงสรุปการใช้เหตุผลของโมเดลในไทม์ไลน์",
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
@@ -655,6 +795,11 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่",
"settings.general.row.newLayoutDesigns.description":
"เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่",
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
@@ -668,6 +813,8 @@ export const dict = {
"settings.updates.row.check.description": "ตรวจสอบการอัปเดตด้วยตนเองและติดตั้งหากมี",
"settings.updates.action.checkNow": "ตรวจสอบทันที",
"settings.updates.action.checking": "กำลังตรวจสอบ...",
"settings.updates.action.downloading": "กำลังดาวน์โหลด...",
"settings.updates.action.installing": "กำลังติดตั้ง...",
"settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว",
"settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด",
@@ -857,10 +1004,10 @@ export const dict = {
"dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้",
"dialog.server.add.passwordPlaceholder": "รหัสผ่าน",
"server.row.noUsername": "ไม่มีชื่อผู้ใช้",
"session.review.noVcs.createGit.title": "สร้าง Git repository",
"session.review.noVcs.createGit.title": "สร้าง Git รีโพซิทอรี",
"session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้",
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git repository...",
"session.review.noVcs.createGit.action": "สร้าง Git repository",
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git รีโพซิทอรี...",
"session.review.noVcs.createGit.action": "สร้าง Git รีโพซิทอรี",
"session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ",
"session.question.progress": "{{current}} จาก {{total}} คำถาม",
"session.header.open.finder": "Finder",
+154 -6
View File
@@ -27,6 +27,9 @@ export const dict = {
"command.sidebar.toggle": "Kenar çubuğunu aç/kapat",
"command.project.open": "Proje aç",
"command.project.previous": "Önceki proje",
"command.project.next": "Sonraki proje",
"command.project.index": "{{index}} numaralı projeye geç",
"command.provider.connect": "Sağlayıcı bağla",
"command.server.switch": "Sunucu değiştir",
"command.settings.open": "Ayarları aç",
@@ -49,6 +52,7 @@ export const dict = {
"command.session.new": "Yeni oturum",
"command.file.open": "Dosya aç",
"command.tab.close": "Sekmeyi kapat",
"command.tab.reopenClosed": "Kapatılan sekmeyi yeniden aç",
"command.context.addSelection": "Seçimi bağlama ekle",
"command.context.addSelection.description": "Mevcut dosyadan seçili satırları ekle",
"command.input.focus": "Girişi odakla",
@@ -132,7 +136,7 @@ export const dict = {
"provider.connect.status.waiting": "Yetkilendirme bekleniyor...",
"provider.connect.status.failed": "Yetkilendirme başarısız: {{error}}",
"provider.connect.apiKey.description":
"{{provider}} hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için {{provider}} API anahtarınızı girin.",
"{{provider}} hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için API anahtarınızı girin.",
"provider.connect.apiKey.label": "{{provider}} API anahtarı",
"provider.connect.apiKey.placeholder": "API anahtarı",
"provider.connect.apiKey.required": "API anahtarı gerekli",
@@ -143,10 +147,10 @@ export const dict = {
"provider.connect.opencodeZen.visit.prefix": "",
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
"provider.connect.opencodeZen.visit.suffix": " adresini ziyaret ederek API anahtarınızı alın.",
"provider.connect.oauth.code.visit.prefix":
"Hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için ",
"provider.connect.oauth.code.visit.prefix": "Yetkilendirme kodunuzu almak için ",
"provider.connect.oauth.code.visit.link": "bu bağlantıya",
"provider.connect.oauth.code.visit.suffix": " tıklayarak yetkilendirme kodunuzu alın.",
"provider.connect.oauth.code.visit.suffix":
" gidin; ardından hesabınızı bağlayarak OpenCode'da {{provider}} modellerini kullanın.",
"provider.connect.oauth.code.label": "{{method}} yetkilendirme kodu",
"provider.connect.oauth.code.placeholder": "Yetkilendirme kodu",
"provider.connect.oauth.code.required": "Yetkilendirme kodu gerekli",
@@ -213,6 +217,11 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Kabul eder: {{inputs}}",
"model.tooltip.context.label": "Bağlam",
"model.tooltip.inputs": "Girdiler",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Sağlayıcı",
"model.tooltip.reasoning": "Akıl yürütme",
"model.tooltip.reasoning.allowed": "Akıl yürütme destekler",
"model.tooltip.reasoning.none": "Akıl yürütme yok",
"model.tooltip.context": "Bağlam limiti {{limit}}",
@@ -223,6 +232,7 @@ export const dict = {
"common.loading": "Yükleniyor",
"common.loading.ellipsis": "...",
"common.cancel": "İptal",
"common.clear": "Temizle",
"common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes",
"common.continue": "Gönder",
@@ -240,6 +250,8 @@ export const dict = {
"prompt.mode.shell": "Kabuk",
"prompt.mode.normal": "Komut",
"prompt.mode.shell.exit": "çıkmak için esc",
"session.child.promptDisabled": "Alt ajan oturumlarına istem gönderilemez.",
"session.child.backToParent": "Ana oturuma dön.",
"prompt.example.1": "Kod tabanındaki bir TODO'yu düzelt",
"prompt.example.2": "Bu projenin teknoloji yığını nedir?",
@@ -346,6 +358,75 @@ export const dict = {
"dialog.server.menu.delete": "Sil",
"dialog.server.current": "Mevcut Sunucu",
"dialog.server.status.default": "Varsayılan",
"wsl.server.add": "WSL sunucusu ekle",
"wsl.server.addShort": "WSL ekle",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL sunucusu",
"wsl.server.retryStart": "Başlatmayı yeniden dene",
"wsl.server.updating": "Güncelleniyor...",
"wsl.onboarding.step.distro": "Dağıtım seç",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "WSL kontrol ediliyor...",
"wsl.onboarding.restartRequired": "WSL kurulumunu tamamlamak için Windows'un yeniden başlatılması gerekiyor.",
"wsl.onboarding.ready": "WSL hazır.",
"wsl.onboarding.required": "Devam etmek için WSL gerekli.",
"wsl.onboarding.checkingDistros": "Dağıtımlar kontrol ediliyor...",
"wsl.onboarding.installingDistro": "{{distro}} kuruluyor...",
"wsl.onboarding.checkingDistro": "{{distro}} kontrol ediliyor...",
"wsl.onboarding.listingDistros": "Dağıtımlar listeleniyor...",
"wsl.onboarding.distroReady": "{{distro}} hazır.",
"wsl.onboarding.distroNotInstalled": "{{distro}} henüz kurulu değil.",
"wsl.onboarding.openDistroOnce": "Kurulumu tamamlamak için {{distro}} dağıtımını bir kez açın.",
"wsl.onboarding.finishingDistro": "{{distro}} kurulumu tamamlanıyor.",
"wsl.onboarding.pickDistro": "Bir dağıtım seçin veya aşağıdan bir dağıtım kurun.",
"wsl.onboarding.checkingOpencode": "OpenCode kontrol ediliyor...",
"wsl.onboarding.checkingOpencodeIn": "{{distro}} içinde OpenCode kontrol ediliyor...",
"wsl.onboarding.updatingOpencode": "OpenCode güncelleniyor...",
"wsl.onboarding.updatingOpencodeIn": "{{distro}} içinde OpenCode güncelleniyor...",
"wsl.onboarding.updateOpencodeIn": "{{distro}} içindeki OpenCode'u güncelleyin.",
"wsl.onboarding.updateOpencode": "OpenCode'u güncelle",
"wsl.onboarding.opencodeReadyIn": "{{distro}} içinde OpenCode hazır.",
"wsl.onboarding.opencodeReady": "OpenCode hazır.",
"wsl.onboarding.installOpencodeIn": "OpenCode'u {{distro}} içine kurun.",
"wsl.onboarding.installOpencode": "OpenCode'u kur",
"wsl.onboarding.chooseDistroFirst": "Önce bir dağıtım seçin.",
"wsl.onboarding.loadFailed": "WSL durumu yüklenemedi.",
"wsl.onboarding.loading": "Yükleniyor...",
"wsl.onboarding.installedDistros": "Kurulu dağıtımlar",
"wsl.onboarding.checkAgain": "Tekrar kontrol et",
"wsl.onboarding.distroStatus.ready": "Hazır",
"wsl.onboarding.distroStatus.checking": "Kontrol ediliyor...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode kurulu değil",
"wsl.onboarding.distroStatus.missingTools": "bash ve curl eksik",
"wsl.onboarding.distroStatus.unsupported": "Desteklenmiyor · WSL 2 kullanın",
"wsl.onboarding.needAnotherDistro": "Başka bir dağıtım mı gerekiyor?",
"wsl.onboarding.needAnotherDistroHint": "WSL kataloğundan bir Linux dağıtımı kurun",
"wsl.onboarding.wslNotInstalled.title": "WSL kurulu değil",
"wsl.onboarding.wslNotInstalled.description":
"OpenCode'un WSL sunucusu ekleyebilmesi için önce WSL (Linux için Windows Alt Sistemi) kurulmalıdır",
"wsl.onboarding.wslUnavailable.title": "WSL kullanılamıyor",
"wsl.onboarding.wslUnavailable.description": "OpenCode bu makinede WSL'yi doğrulayamadı.",
"wsl.onboarding.installWsl": "WSL'yi kur",
"wsl.onboarding.windowsRestartRequired":
"WSL kurulumunu tamamlamak için Windows'u yeniden başlatın, ardından OpenCode'u tekrar açın.",
"wsl.onboarding.next": "İleri",
"wsl.onboarding.refresh": "Yenile",
"wsl.onboarding.allDistrosAdded": "Kurulu tüm dağıtımlar zaten eklenmiş.",
"wsl.onboarding.noDistros": "Henüz dağıtım algılanmadı.",
"wsl.onboarding.install": "Kur",
"wsl.onboarding.installing": "Kuruluyor...",
"wsl.onboarding.installDistro": "Dağıtım kur",
"wsl.onboarding.searchDistros": "Dağıtım ara",
"wsl.onboarding.wsl2Required": "WSL 2 gerekli.",
"wsl.onboarding.toolsRequired": "Bu dağıtım için bash ve curl gerekli.",
"wsl.onboarding.openTerminal": "Terminali aç",
"wsl.onboarding.path": "Yol: {{path}}",
"wsl.onboarding.notFound": "bulunamadı",
"wsl.onboarding.version": "Sürüm: {{version}}",
"wsl.onboarding.unknown": "bilinmiyor",
"wsl.onboarding.desktopVersion": "masaüstü {{version}}",
"wsl.onboarding.versionMismatch": "Kurulu sürüm masaüstü uygulamasının sürümüyle eşleşmiyor.",
"wsl.onboarding.adding": "Ekleniyor...",
"dialog.project.edit.title": "Projeyi düzenle",
"dialog.project.edit.name": "Ad",
@@ -408,6 +489,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -456,10 +538,12 @@ export const dict = {
"error.page.title": "Bir şeyler yanlış gitti",
"error.page.description": "Uygulama yüklenirken bir hata oluştu.",
"error.page.description.localServerStartup": "Yerel sunucu başlatılırken bir hata oluştu.",
"error.page.details.label": "Hata Detayları",
"error.page.action.restart": "Yeniden Başlat",
"error.page.action.report": "Hatayı Bildir",
"error.page.action.reported": "Hata Bildirildi",
"error.page.action.exportLogs": "Günlükleri Dışa Aktar",
"error.page.action.checking": "Kontrol ediliyor...",
"error.page.action.checkUpdates": "Güncellemeleri kontrol et",
"error.page.action.updateTo": "{{version}} sürümüne güncelle",
@@ -509,11 +593,31 @@ export const dict = {
"home.recentProjects": "Son projeler",
"home.empty.title": "Son proje yok",
"home.empty.description": "Yerel bir proje açarak başlayın",
"home.title": "Ana Sayfa",
"home.projects": "Projeler",
"home.project.add": "Proje ekle",
"home.recentlyClosed": "Yakın zamanda kapatılanlar",
"home.server.collapse": "Sunucu projelerini daralt",
"home.server.expand": "Sunucu projelerini genişlet",
"home.sessions.search.placeholder": "Oturum ara",
"home.sessions.search.placeholder.scoped": "{{scope}} kapsamında oturum ara",
"home.sessions.search.sessions": "Oturumlar",
"home.sessions.search.noResults": "{{query}} için oturum bulunamadı",
"home.sessions.empty": "Henüz burada bir şey yok",
"home.sessions.empty.description": "Başlamak için bir oturum oluşturun",
"home.sessions.group.today": "Bugün",
"home.sessions.group.yesterday": "Dün",
"home.sessions.group.older": "Daha eski",
"session.tab.session": "Oturum",
"session.tab.review": "İnceleme",
"session.tab.context": "Bağlam",
"session.tab.unknown": "Bilinmeyen Oturum",
"session.panel.reviewAndFiles": "İnceleme ve dosyalar",
"session.error.notFound": "Bu oturum bulunamıyor",
"session.error.notFound.description": "Bu sekme, artık bu sunucuda bulunmayan bir oturuma yönlendiriyor.",
"session.error.notFound.closeTab": "Sekmeyi Kapat",
"session.error.serverConnection": "Bu sunucuya bağlanılamıyor",
"session.review.filesChanged": "{{count}} Dosya Değişti",
"session.review.change.one": "Değişiklik",
"session.review.change.other": "Değişiklik",
@@ -523,6 +627,8 @@ export const dict = {
"session.review.noSnapshot":
"Yapılandırmada anlık görüntü takibi devre dışı bırakıldı, bu nedenle oturum değişiklikleri kullanılamıyor",
"session.review.noChanges": "Değişiklik yok",
"session.review.noUncommittedChanges": "Henüz commit edilmemiş değişiklik yok",
"session.review.noBranchChanges": "Henüz dal değişikliği yok",
"session.files.selectToOpen": "Açmak için bir dosya seçin",
"session.files.all": "Tüm dosyalar",
@@ -539,6 +645,10 @@ export const dict = {
"session.todo.title": "Görevler",
"session.todo.collapse": "Daralt",
"session.todo.expand": "Genişlet",
"session.question.minimize": "Soruyu küçült",
"session.question.restore": "Soruyu geri yükle",
"session.question.pending.one": "{{count}} bekleyen soru",
"session.question.pending.other": "{{count}} bekleyen soru",
"session.followupDock.summary.one": "{{count}} sıradaki mesaj",
"session.followupDock.summary.other": "{{count}} sıradaki mesaj",
"session.followupDock.sendNow": "Şimdi gönder",
@@ -552,9 +662,16 @@ export const dict = {
"session.revertDock.restore": "Mesajı geri yükle",
"session.new.title": "İstediğini yap",
"session.new.project.new": "Yeni proje",
"session.new.project.search": "Proje ara",
"session.new.project.add": "Proje ekle",
"session.new.worktree.main": "Ana dal",
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
"session.new.workspace.runIn": "Oturumu şurada çalıştır",
"session.new.workspace.triggerLocal": "Yerel",
"session.new.workspace.local": "Yerel depo",
"session.new.workspace.existing": "Çalışma alanı…",
"session.new.lastModified": "Son değişiklik",
"session.header.search.placeholder": "{{project}} ara",
@@ -624,6 +741,8 @@ export const dict = {
"sidebar.project.recentSessions": "Son oturumlar",
"sidebar.project.viewAllSessions": "Tüm oturumları görüntüle",
"sidebar.project.clearNotifications": "Bildirimleri temizle",
"sidebar.empty.title": "Açık proje yok",
"sidebar.empty.description": "Başlamak için bir proje açın",
"app.name.desktop": "OpenCode Masaüstü",
@@ -636,6 +755,7 @@ export const dict = {
"settings.desktop.wsl.description": "OpenCode sunucusunu Windows'ta WSL içinde çalıştırın.",
"settings.general.section.appearance": "Görünüm",
"settings.general.section.advanced": "Gelişmiş",
"settings.general.section.notifications": "Sistem bildirimleri",
"settings.general.section.updates": "Güncellemeler",
"settings.general.section.sounds": "Ses efektleri",
@@ -644,6 +764,11 @@ export const dict = {
"settings.general.row.language.title": "Dil",
"settings.general.row.language.description": "OpenCode'un görünüm dilini değiştirin",
"settings.general.row.shell.title": "Terminal Kabuğu",
"settings.general.row.shell.description":
"Terminalinizde kullanılacak kabuğu seçin. Uyumlu kabuklar, ajan araç çağrılarında da kullanılır.",
"settings.general.row.shell.autoDefault": "Otomatik (Varsayılan)",
"settings.general.row.shell.terminalOnly": "yalnızca terminal",
"settings.general.row.appearance.title": "Görünüm",
"settings.general.row.appearance.description": "OpenCode'un cihazınızdaki görünümünü özelleştirin",
"settings.general.row.colorScheme.title": "Renk şeması",
@@ -653,8 +778,8 @@ export const dict = {
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
"settings.general.row.font.title": "Kod Yazı Tipi",
"settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "Terminal yazı tipi",
"settings.general.row.terminalFont.description": "Terminalde 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ışı",
@@ -662,6 +787,21 @@ export const dict = {
"Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
"settings.general.row.followup.option.queue": "Sıra",
"settings.general.row.followup.option.steer": "Yönlendir",
"settings.general.row.showFileTree.title": "Dosya ağacı",
"settings.general.row.showFileTree.description": "Oturumlarda dosya ağacı panelini göster",
"settings.general.row.showNavigation.title": "Gezinme denetimleri",
"settings.general.row.showNavigation.description": "Masaüstü başlık çubuğunda geri ve ileri düğmelerini göster",
"settings.general.row.showSearch.title": "Komut paleti",
"settings.general.row.showSearch.description": "Başlık çubuğunda arama ve komut paleti düğmesini göster",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Masaüstü başlık çubuğunda terminal düğmesini göster",
"settings.general.row.showStatus.title": "Sunucu durumu",
"settings.general.row.showStatus.description": "Başlık çubuğunda sunucu durumu düğmesini göster",
"settings.general.row.mobileTitlebarBottom.title": "Alt gezinme",
"settings.general.row.mobileTitlebarBottom.description":
"Mobil cihazlarda başlık çubuğunu ve oturum sekmelerini ekranın altına yerleştir",
"settings.general.row.showCustomAgents.title": "Özel ajanlar",
"settings.general.row.showCustomAgents.description": "Yazma alanında ajan seçiciyi göster",
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
@@ -670,6 +810,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
"settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar",
"settings.general.row.newLayoutDesigns.description":
"Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir",
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
"settings.general.row.pinchZoom.description":
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",
"settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description":
@@ -686,6 +832,8 @@ export const dict = {
"settings.updates.row.check.description": "Elle güncelleme kontrolü yap ve varsa yükle",
"settings.updates.action.checkNow": "Şimdi kontrol et",
"settings.updates.action.checking": "Kontrol ediliyor...",
"settings.updates.action.downloading": "İndiriliyor...",
"settings.updates.action.installing": "Kuruluyor...",
"settings.updates.toast.latest.title": "Güncelsiniz",
"settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.",
+122
View File
@@ -48,6 +48,7 @@ export const dict = {
"command.session.new": "Нова сесія",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.tab.reopenClosed": "Повторно відкрити закриту вкладку",
"command.context.addSelection": "Додати виділення до контексту",
"command.context.addSelection.description": "Додати вибрані рядки з поточного файлу",
"command.input.focus": "Фокус на полі введення",
@@ -212,6 +213,11 @@ export const dict = {
"model.input.video": "відео",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Дозволяє: {{inputs}}",
"model.tooltip.context.label": "Контекст",
"model.tooltip.inputs": "Вхідні дані",
"model.tooltip.model": "Модель",
"model.tooltip.provider": "Провайдер",
"model.tooltip.reasoning": "Мислення",
"model.tooltip.reasoning.allowed": "Підтримує мислення",
"model.tooltip.reasoning.none": "Без мислення",
"model.tooltip.context": "Ліміт контексту {{limit}}",
@@ -222,6 +228,7 @@ export const dict = {
"common.loading": "Завантаження",
"common.loading.ellipsis": "...",
"common.cancel": "Скасувати",
"common.clear": "Очистити",
"common.open": "Відкрити",
"common.connect": "Підключити",
"common.disconnect": "Відключити",
@@ -354,6 +361,75 @@ export const dict = {
"dialog.server.menu.delete": "Видалити",
"dialog.server.current": "Поточний сервер",
"dialog.server.status.default": "За замовчуванням",
"wsl.server.add": "Додати сервер WSL",
"wsl.server.addShort": "Додати WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "Сервер WSL",
"wsl.server.retryStart": "Повторити запуск",
"wsl.server.updating": "Оновлення...",
"wsl.onboarding.step.distro": "Вибрати дистрибутив",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "Перевірка WSL...",
"wsl.onboarding.restartRequired": "Щоб завершити встановлення WSL, потрібно перезапустити Windows.",
"wsl.onboarding.ready": "WSL готова до роботи.",
"wsl.onboarding.required": "Для продовження потрібна WSL.",
"wsl.onboarding.checkingDistros": "Перевірка дистрибутивів...",
"wsl.onboarding.installingDistro": "Встановлення {{distro}}...",
"wsl.onboarding.checkingDistro": "Перевірка {{distro}}...",
"wsl.onboarding.listingDistros": "Отримання списку дистрибутивів...",
"wsl.onboarding.distroReady": "{{distro}} готовий до роботи.",
"wsl.onboarding.distroNotInstalled": "{{distro}} ще не встановлено.",
"wsl.onboarding.openDistroOnce": "Відкрийте {{distro}} один раз, щоб завершити налаштування.",
"wsl.onboarding.finishingDistro": "Завершення налаштування {{distro}}.",
"wsl.onboarding.pickDistro": "Виберіть дистрибутив або встановіть один із наведених нижче.",
"wsl.onboarding.checkingOpencode": "Перевірка OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "Перевірка OpenCode у {{distro}}...",
"wsl.onboarding.updatingOpencode": "Оновлення OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "Оновлення OpenCode у {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "Оновіть OpenCode у {{distro}}.",
"wsl.onboarding.updateOpencode": "Оновити OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode готовий до роботи у {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode готовий до роботи.",
"wsl.onboarding.installOpencodeIn": "Встановіть OpenCode у {{distro}}.",
"wsl.onboarding.installOpencode": "Встановити OpenCode",
"wsl.onboarding.chooseDistroFirst": "Спочатку виберіть дистрибутив.",
"wsl.onboarding.loadFailed": "Не вдалося завантажити стан WSL.",
"wsl.onboarding.loading": "Завантаження...",
"wsl.onboarding.installedDistros": "Встановлені дистрибутиви",
"wsl.onboarding.checkAgain": "Перевірити ще раз",
"wsl.onboarding.distroStatus.ready": "Готовий",
"wsl.onboarding.distroStatus.checking": "Перевірка...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode не встановлено",
"wsl.onboarding.distroStatus.missingTools": "Немає bash і curl",
"wsl.onboarding.distroStatus.unsupported": "Не підтримується · Використовуйте WSL 2",
"wsl.onboarding.needAnotherDistro": "Потрібен інший дистрибутив?",
"wsl.onboarding.needAnotherDistroHint": "Встановіть дистрибутив Linux із каталогу WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL не встановлено",
"wsl.onboarding.wslNotInstalled.description":
"Для додавання сервера WSL в OpenCode потрібна WSL (Підсистема Windows для Linux)",
"wsl.onboarding.wslUnavailable.title": "WSL недоступна",
"wsl.onboarding.wslUnavailable.description": "OpenCode не вдалося перевірити WSL на цьому комп'ютері.",
"wsl.onboarding.installWsl": "Встановити WSL",
"wsl.onboarding.windowsRestartRequired":
"Перезапустіть Windows, щоб завершити встановлення WSL, а потім знову відкрийте OpenCode.",
"wsl.onboarding.next": "Далі",
"wsl.onboarding.refresh": "Оновити",
"wsl.onboarding.allDistrosAdded": "Усі встановлені дистрибутиви вже додано.",
"wsl.onboarding.noDistros": "Дистрибутивів поки не виявлено.",
"wsl.onboarding.install": "Встановити",
"wsl.onboarding.installing": "Встановлення...",
"wsl.onboarding.installDistro": "Встановити дистрибутив",
"wsl.onboarding.searchDistros": "Пошук дистрибутивів",
"wsl.onboarding.wsl2Required": "Для роботи потрібна WSL 2.",
"wsl.onboarding.toolsRequired": "Для цього дистрибутива потрібні bash і curl.",
"wsl.onboarding.openTerminal": "Відкрити термінал",
"wsl.onboarding.path": "Шлях: {{path}}",
"wsl.onboarding.notFound": "не знайдено",
"wsl.onboarding.version": "Версія: {{version}}",
"wsl.onboarding.unknown": "невідомо",
"wsl.onboarding.desktopVersion": "десктопний застосунок {{version}}",
"wsl.onboarding.versionMismatch": "Встановлена версія не відповідає версії десктопного застосунку.",
"wsl.onboarding.adding": "Додавання...",
"server.row.noUsername": "без імені користувача",
"dialog.project.edit.title": "Редагувати проєкт",
@@ -473,10 +549,12 @@ export const dict = {
"error.page.title": "Щось пішло не так",
"error.page.description": "Під час завантаження програми сталася помилка.",
"error.page.description.localServerStartup": "Під час запуску локального сервера сталася помилка.",
"error.page.details.label": "Деталі помилки",
"error.page.action.restart": "Перезапустити",
"error.page.action.report": "Повідомити про помилку",
"error.page.action.reported": "Помилку повідомлено",
"error.page.action.exportLogs": "Експортувати журнали",
"error.page.action.checking": "Перевірка...",
"error.page.action.checkUpdates": "Перевірити оновлення",
"error.page.action.updateTo": "Оновити до {{version}}",
@@ -533,11 +611,31 @@ export const dict = {
"home.recentProjects": "Нещодавні проєкти",
"home.empty.title": "Немає нещодавніх проєктів",
"home.empty.description": "Почніть, відкривши локальний проєкт",
"home.title": "Головна",
"home.projects": "Проєкти",
"home.project.add": "Додати проєкт",
"home.recentlyClosed": "Нещодавно закриті",
"home.server.collapse": "Згорнути проєкти сервера",
"home.server.expand": "Розгорнути проєкти сервера",
"home.sessions.search.placeholder": "Пошук сесій",
"home.sessions.search.placeholder.scoped": "Пошук сесій у {{scope}}",
"home.sessions.search.sessions": "Сесії",
"home.sessions.search.noResults": "Сесій за запитом {{query}} не знайдено",
"home.sessions.empty": "Тут поки нічого немає",
"home.sessions.empty.description": "Створіть сесію, щоб почати",
"home.sessions.group.today": "Сьогодні",
"home.sessions.group.yesterday": "Учора",
"home.sessions.group.older": "Раніше",
"session.tab.session": "Сесія",
"session.tab.review": "Огляд",
"session.tab.context": "Контекст",
"session.tab.unknown": "Невідома сесія",
"session.panel.reviewAndFiles": "Огляд і файли",
"session.error.notFound": "Цю сесію не знайдено",
"session.error.notFound.description": "Ця вкладка посилається на сесію, якої більше немає на цьому сервері.",
"session.error.notFound.closeTab": "Закрити вкладку",
"session.error.serverConnection": "Не вдається підключитися до цього сервера",
"session.review.filesChanged": "Змінено файлів: {{count}}",
"session.review.change.one": "Зміна",
"session.review.change.other": "Зміни",
@@ -570,6 +668,10 @@ export const dict = {
"session.todo.expand": "Розгорнути",
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
"session.question.progress": "{{current}} з {{total}} запитань",
"session.question.minimize": "Згорнути запитання",
"session.question.restore": "Відновити запитання",
"session.question.pending.one": "{{count}} запитання очікує відповіді",
"session.question.pending.other": "Запитання, що очікують відповіді: {{count}}",
"session.followupDock.summary.one": "{{count}} повідомлення в черзі",
"session.followupDock.summary.other": "{{count}} повідомлень у черзі",
"session.followupDock.sendNow": "Надіслати зараз",
@@ -583,9 +685,16 @@ export const dict = {
"session.revertDock.restore": "Відновити повідомлення",
"session.new.title": "Створити що завгодно",
"session.new.project.new": "Новий проєкт",
"session.new.project.search": "Пошук проєктів",
"session.new.project.add": "Додати проєкт",
"session.new.worktree.main": "Основна гілка",
"session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})",
"session.new.worktree.create": "Створити нове робоче дерево",
"session.new.workspace.runIn": "Запустити сесію в",
"session.new.workspace.triggerLocal": "Локально",
"session.new.workspace.local": "Локальний репозиторій",
"session.new.workspace.existing": "Робоча область…",
"session.new.lastModified": "Востаннє змінено",
"session.header.search.placeholder": "Пошук {{project}}",
@@ -773,6 +882,11 @@ export const dict = {
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
"settings.general.row.showStatus.title": "Статус сервера",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку",
"settings.general.row.mobileTitlebarBottom.title": "Нижня навігація",
"settings.general.row.mobileTitlebarBottom.description":
"Розмістити заголовок і вкладки сесії внизу екрана на мобільних пристроях",
"settings.general.row.showCustomAgents.title": "Користувацькі агенти",
"settings.general.row.showCustomAgents.description": "Показувати вибір агента в редакторі запиту",
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
@@ -781,6 +895,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн",
"settings.general.row.newLayoutDesigns.description":
"Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії",
"settings.general.row.pinchZoom.title": "Масштабування щипком",
"settings.general.row.pinchZoom.description":
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",
"settings.general.row.wayland.title": "Використовувати нативний Wayland",
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",
@@ -796,6 +916,8 @@ export const dict = {
"settings.updates.row.check.description": "Вручну перевірити наявність оновлень і встановити, якщо доступні",
"settings.updates.action.checkNow": "Перевірити зараз",
"settings.updates.action.checking": "Перевірка...",
"settings.updates.action.downloading": "Завантаження...",
"settings.updates.action.installing": "Встановлення...",
"settings.updates.toast.latest.title": "У вас актуальна версія",
"settings.updates.toast.latest.description": "Ви використовуєте останню версію OpenCode.",
"sound.option.none": "Немає",
+132 -2
View File
@@ -28,6 +28,9 @@ export const dict = {
"command.sidebar.toggle": "切换侧边栏",
"command.project.open": "打开项目",
"command.project.previous": "上一个项目",
"command.project.next": "下一个项目",
"command.project.index": "切换到项目 {{index}}",
"command.provider.connect": "连接提供商",
@@ -56,6 +59,7 @@ export const dict = {
"command.file.open": "打开文件",
"command.tab.close": "关闭标签页",
"command.tab.reopenClosed": "重新打开已关闭的标签页",
"command.context.addSelection": "将所选内容添加到上下文",
"command.context.addSelection.description": "添加当前文件中选中的行",
@@ -230,6 +234,11 @@ export const dict = {
"model.input.video": "视频",
"model.input.pdf": "pdf",
"model.tooltip.allows": "支持:{{inputs}}",
"model.tooltip.context.label": "上下文",
"model.tooltip.inputs": "输入",
"model.tooltip.model": "模型",
"model.tooltip.provider": "提供商",
"model.tooltip.reasoning": "推理",
"model.tooltip.reasoning.allowed": "支持推理",
"model.tooltip.reasoning.none": "不支持推理",
"model.tooltip.context": "上下文上限 {{limit}}",
@@ -240,6 +249,7 @@ export const dict = {
"common.loading": "加载中",
"common.loading.ellipsis": "...",
"common.cancel": "取消",
"common.clear": "清除",
"common.connect": "连接",
"common.disconnect": "断开连接",
"common.continue": "提交",
@@ -257,6 +267,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "按 esc 退出",
"session.child.promptDisabled": "无法向子智能体会话发送提示词。",
"session.child.backToParent": "返回主会话。",
"prompt.example.1": "修复代码库中的一个 TODO",
"prompt.example.2": "这个项目的技术栈是什么?",
"prompt.example.3": "修复失败的测试",
@@ -359,6 +371,73 @@ export const dict = {
"dialog.server.menu.delete": "删除",
"dialog.server.current": "当前服务器",
"dialog.server.status.default": "默认",
"wsl.server.add": "添加 WSL 服务器",
"wsl.server.addShort": "添加 WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL 服务器",
"wsl.server.retryStart": "重试启动",
"wsl.server.updating": "正在更新...",
"wsl.onboarding.step.distro": "选择发行版",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "正在检查 WSL...",
"wsl.onboarding.restartRequired": "Windows 需要重启才能完成 WSL 安装。",
"wsl.onboarding.ready": "WSL 已就绪。",
"wsl.onboarding.required": "需要 WSL 才能继续。",
"wsl.onboarding.checkingDistros": "正在检查发行版...",
"wsl.onboarding.installingDistro": "正在安装 {{distro}}...",
"wsl.onboarding.checkingDistro": "正在检查 {{distro}}...",
"wsl.onboarding.listingDistros": "正在列出发行版...",
"wsl.onboarding.distroReady": "{{distro}} 已就绪。",
"wsl.onboarding.distroNotInstalled": "{{distro}} 尚未安装。",
"wsl.onboarding.openDistroOnce": "打开一次 {{distro}} 以完成设置。",
"wsl.onboarding.finishingDistro": "正在完成 {{distro}} 的设置。",
"wsl.onboarding.pickDistro": "选择一个发行版,或在下方安装。",
"wsl.onboarding.checkingOpencode": "正在检查 OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "正在检查 {{distro}} 中的 OpenCode...",
"wsl.onboarding.updatingOpencode": "正在更新 OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "正在更新 {{distro}} 中的 OpenCode...",
"wsl.onboarding.updateOpencodeIn": "更新 {{distro}} 中的 OpenCode。",
"wsl.onboarding.updateOpencode": "更新 OpenCode",
"wsl.onboarding.opencodeReadyIn": "{{distro}} 中的 OpenCode 已就绪。",
"wsl.onboarding.opencodeReady": "OpenCode 已就绪。",
"wsl.onboarding.installOpencodeIn": "在 {{distro}} 中安装 OpenCode。",
"wsl.onboarding.installOpencode": "安装 OpenCode",
"wsl.onboarding.chooseDistroFirst": "请先选择发行版。",
"wsl.onboarding.loadFailed": "加载 WSL 状态失败。",
"wsl.onboarding.loading": "加载中...",
"wsl.onboarding.installedDistros": "已安装的发行版",
"wsl.onboarding.checkAgain": "重新检查",
"wsl.onboarding.distroStatus.ready": "已就绪",
"wsl.onboarding.distroStatus.checking": "检查中...",
"wsl.onboarding.distroStatus.opencodeMissing": "未安装 OpenCode",
"wsl.onboarding.distroStatus.missingTools": "缺少 bash、curl",
"wsl.onboarding.distroStatus.unsupported": "不支持 · 请使用 WSL 2",
"wsl.onboarding.needAnotherDistro": "需要其他发行版?",
"wsl.onboarding.needAnotherDistroHint": "从 WSL 目录安装 Linux 发行版",
"wsl.onboarding.wslNotInstalled.title": "未安装 WSL",
"wsl.onboarding.wslNotInstalled.description": "OpenCode 需要 WSLWindows Subsystem for Linux)才能添加 WSL 服务器",
"wsl.onboarding.wslUnavailable.title": "WSL 不可用",
"wsl.onboarding.wslUnavailable.description": "OpenCode 无法验证此设备上的 WSL。",
"wsl.onboarding.installWsl": "安装 WSL",
"wsl.onboarding.windowsRestartRequired": "重启 Windows 以完成 WSL 安装,然后重新打开 OpenCode。",
"wsl.onboarding.next": "下一步",
"wsl.onboarding.refresh": "刷新",
"wsl.onboarding.allDistrosAdded": "所有已安装的发行版均已添加。",
"wsl.onboarding.noDistros": "尚未检测到发行版。",
"wsl.onboarding.install": "安装",
"wsl.onboarding.installing": "正在安装...",
"wsl.onboarding.installDistro": "安装发行版",
"wsl.onboarding.searchDistros": "搜索发行版",
"wsl.onboarding.wsl2Required": "需要 WSL 2。",
"wsl.onboarding.toolsRequired": "此发行版需要 bash 和 curl。",
"wsl.onboarding.openTerminal": "打开终端",
"wsl.onboarding.path": "路径:{{path}}",
"wsl.onboarding.notFound": "未找到",
"wsl.onboarding.version": "版本:{{version}}",
"wsl.onboarding.unknown": "未知",
"wsl.onboarding.desktopVersion": "桌面版 {{version}}",
"wsl.onboarding.versionMismatch": "已安装版本与桌面应用版本不一致。",
"wsl.onboarding.adding": "正在添加...",
"dialog.project.edit.title": "编辑项目",
"dialog.project.edit.name": "名称",
@@ -418,6 +497,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -456,10 +536,12 @@ export const dict = {
"error.page.title": "出了点问题",
"error.page.description": "加载应用程序时发生错误。",
"error.page.description.localServerStartup": "启动本地服务器时发生错误。",
"error.page.details.label": "错误详情",
"error.page.action.restart": "重启",
"error.page.action.report": "上报错误",
"error.page.action.reported": "错误已上报",
"error.page.action.exportLogs": "导出日志",
"error.page.action.checking": "检查中...",
"error.page.action.checkUpdates": "检查更新",
"error.page.action.updateTo": "更新到 {{version}}",
@@ -506,6 +588,9 @@ export const dict = {
"home.title": "主页",
"home.projects": "项目",
"home.project.add": "添加项目",
"home.recentlyClosed": "最近关闭",
"home.server.collapse": "折叠服务器项目",
"home.server.expand": "展开服务器项目",
"home.sessions.search.placeholder": "搜索会话",
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话",
"home.sessions.search.sessions": "会话",
@@ -519,7 +604,12 @@ export const dict = {
"session.tab.session": "会话",
"session.tab.review": "审查",
"session.tab.context": "上下文",
"session.tab.unknown": "未知会话",
"session.panel.reviewAndFiles": "审查和文件",
"session.error.notFound": "找不到此会话",
"session.error.notFound.description": "此标签页指向的会话已不存在于此服务器上。",
"session.error.notFound.closeTab": "关闭标签页",
"session.error.serverConnection": "无法连接到此服务器",
"session.review.filesChanged": "{{count}} 个文件变更",
"session.review.change.one": "更改",
"session.review.change.other": "更改",
@@ -528,6 +618,8 @@ export const dict = {
"session.review.noVcs": "未检测到 Git 版本控制系统,无法显示更改",
"session.review.noSnapshot": "配置中已禁用快照跟踪,因此会话更改不可用",
"session.review.noChanges": "无更改",
"session.review.noUncommittedChanges": "尚无未提交的更改",
"session.review.noBranchChanges": "尚无分支更改",
"session.files.selectToOpen": "选择要打开的文件",
"session.files.all": "所有文件",
"session.files.empty": "无文件",
@@ -541,6 +633,10 @@ export const dict = {
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
"session.todo.expand": "展开",
"session.question.minimize": "最小化问题",
"session.question.restore": "恢复问题",
"session.question.pending.one": "{{count}} 个待处理问题",
"session.question.pending.other": "{{count}} 个待处理问题",
"session.followupDock.summary.one": "{{count}} 条排队消息",
"session.followupDock.summary.other": "{{count}} 条排队消息",
"session.followupDock.sendNow": "立即发送",
@@ -553,9 +649,16 @@ export const dict = {
"session.revertDock.expand": "展开已回滚消息",
"session.revertDock.restore": "恢复消息",
"session.new.title": "构建任何东西",
"session.new.project.new": "新建项目",
"session.new.project.search": "搜索项目",
"session.new.project.add": "添加项目",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支({{branch}}",
"session.new.worktree.create": "创建新的 worktree",
"session.new.workspace.runIn": "运行会话的位置",
"session.new.workspace.triggerLocal": "本地",
"session.new.workspace.local": "本地仓库",
"session.new.workspace.existing": "工作区…",
"session.new.lastModified": "最后修改",
"session.header.search.placeholder": "搜索 {{project}}",
"session.header.searchFiles": "搜索文件",
@@ -623,6 +726,8 @@ export const dict = {
"sidebar.project.recentSessions": "最近会话",
"sidebar.project.viewAllSessions": "查看全部会话",
"sidebar.project.clearNotifications": "清除通知",
"sidebar.empty.title": "没有打开的项目",
"sidebar.empty.description": "打开一个项目以开始使用",
"app.name.desktop": "OpenCode Desktop",
@@ -637,6 +742,7 @@ export const dict = {
"settings.desktop.wsl.description": "在 Windows 的 WSL 环境中运行 OpenCode 服务器。",
"settings.general.section.appearance": "外观",
"settings.general.section.advanced": "高级",
"settings.general.section.notifications": "系统通知",
"settings.general.section.updates": "更新",
"settings.general.section.sounds": "音效",
@@ -644,6 +750,10 @@ export const dict = {
"settings.general.section.display": "显示",
"settings.general.row.language.title": "语言",
"settings.general.row.language.description": "更改 OpenCode 的显示语言",
"settings.general.row.shell.title": "终端 Shell",
"settings.general.row.shell.description": "选择终端使用的 shell。兼容的 shell 也会用于智能体工具调用。",
"settings.general.row.shell.autoDefault": "自动(默认)",
"settings.general.row.shell.terminalOnly": "仅终端",
"settings.general.row.appearance.title": "外观",
"settings.general.row.appearance.description": "自定义 OpenCode 在你的设备上的外观",
"settings.general.row.colorScheme.title": "配色方案",
@@ -652,20 +762,38 @@ export const dict = {
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
"settings.general.row.font.title": "代码字体",
"settings.general.row.font.description": "自定义代码块使用的字体",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "终端字体",
"settings.general.row.terminalFont.description": "自定义终端使用的字体",
"settings.general.row.uiFont.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为",
"settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待",
"settings.general.row.followup.option.queue": "排队",
"settings.general.row.followup.option.steer": "引导",
"settings.general.row.showFileTree.title": "文件树",
"settings.general.row.showFileTree.description": "在会话中显示文件树面板",
"settings.general.row.showNavigation.title": "导航控件",
"settings.general.row.showNavigation.description": "在桌面版标题栏中显示前进和后退按钮",
"settings.general.row.showSearch.title": "命令面板",
"settings.general.row.showSearch.description": "在标题栏中显示搜索和命令面板按钮",
"settings.general.row.showTerminal.title": "终端",
"settings.general.row.showTerminal.description": "在桌面版标题栏中显示终端按钮",
"settings.general.row.showStatus.title": "服务器状态",
"settings.general.row.showStatus.description": "在标题栏中显示服务器状态按钮",
"settings.general.row.mobileTitlebarBottom.title": "底部导航",
"settings.general.row.mobileTitlebarBottom.description": "在移动设备上将标题栏和会话标签页置于屏幕底部",
"settings.general.row.showCustomAgents.title": "自定义智能体",
"settings.general.row.showCustomAgents.description": "在输入框中显示智能体选择器",
"settings.general.row.reasoningSummaries.title": "显示推理摘要",
"settings.general.row.reasoningSummaries.description": "在时间线中显示模型推理摘要",
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
"settings.general.row.newLayoutDesigns.title": "新版布局和设计",
"settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面",
"settings.general.row.pinchZoom.title": "双指缩放",
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
@@ -678,6 +806,8 @@ export const dict = {
"settings.updates.row.check.description": "手动检查更新并在有更新时安装",
"settings.updates.action.checkNow": "立即检查",
"settings.updates.action.checking": "正在检查...",
"settings.updates.action.downloading": "正在下载...",
"settings.updates.action.installing": "正在安装...",
"settings.updates.toast.latest.title": "已是最新版本",
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
+144 -2
View File
@@ -27,6 +27,9 @@ export const dict = {
"command.sidebar.toggle": "切換側邊欄",
"command.project.open": "開啟專案",
"command.project.previous": "上一個專案",
"command.project.next": "下一個專案",
"command.project.index": "切換至專案 {{index}}",
"command.provider.connect": "連接提供者",
"command.server.switch": "切換伺服器",
"command.settings.open": "開啟設定",
@@ -49,6 +52,7 @@ export const dict = {
"command.session.new": "新增工作階段",
"command.file.open": "開啟檔案",
"command.tab.close": "關閉分頁",
"command.tab.reopenClosed": "重新開啟已關閉的分頁",
"command.context.addSelection": "將選取內容加入上下文",
"command.context.addSelection.description": "加入目前檔案中選取的行",
"command.input.focus": "聚焦輸入框",
@@ -209,6 +213,11 @@ export const dict = {
"model.input.video": "影片",
"model.input.pdf": "pdf",
"model.tooltip.allows": "支援: {{inputs}}",
"model.tooltip.context.label": "上下文",
"model.tooltip.inputs": "輸入",
"model.tooltip.model": "模型",
"model.tooltip.provider": "供應商",
"model.tooltip.reasoning": "推理",
"model.tooltip.reasoning.allowed": "支援推理",
"model.tooltip.reasoning.none": "不支援推理",
"model.tooltip.context": "上下文上限 {{limit}}",
@@ -218,6 +227,7 @@ export const dict = {
"common.loading": "載入中",
"common.loading.ellipsis": "...",
"common.cancel": "取消",
"common.clear": "清除",
"common.connect": "連線",
"common.disconnect": "中斷連線",
"common.continue": "提交",
@@ -235,6 +245,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "按 esc 退出",
"session.child.promptDisabled": "無法在子代理程式工作階段中輸入提示詞。",
"session.child.backToParent": "返回主要工作階段。",
"prompt.example.1": "修復程式碼庫中的一個 TODO",
"prompt.example.2": "這個專案的技術堆疊是什麼?",
@@ -340,6 +352,73 @@ export const dict = {
"dialog.server.menu.delete": "刪除",
"dialog.server.current": "目前伺服器",
"dialog.server.status.default": "預設",
"wsl.server.add": "新增 WSL 伺服器",
"wsl.server.addShort": "新增 WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL 伺服器",
"wsl.server.retryStart": "重試啟動",
"wsl.server.updating": "更新中...",
"wsl.onboarding.step.distro": "選擇發行版",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "正在檢查 WSL...",
"wsl.onboarding.restartRequired": "Windows 需要重新啟動,才能完成 WSL 安裝。",
"wsl.onboarding.ready": "WSL 已就緒。",
"wsl.onboarding.required": "需要 WSL 才能繼續。",
"wsl.onboarding.checkingDistros": "正在檢查發行版...",
"wsl.onboarding.installingDistro": "正在安裝 {{distro}}...",
"wsl.onboarding.checkingDistro": "正在檢查 {{distro}}...",
"wsl.onboarding.listingDistros": "正在列出發行版...",
"wsl.onboarding.distroReady": "{{distro}} 已就緒。",
"wsl.onboarding.distroNotInstalled": "尚未安裝 {{distro}}。",
"wsl.onboarding.openDistroOnce": "開啟 {{distro}} 一次以完成設定。",
"wsl.onboarding.finishingDistro": "正在完成 {{distro}} 的設定。",
"wsl.onboarding.pickDistro": "選擇發行版,或在下方安裝。",
"wsl.onboarding.checkingOpencode": "正在檢查 OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "正在檢查 {{distro}} 中的 OpenCode...",
"wsl.onboarding.updatingOpencode": "正在更新 OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "正在更新 {{distro}} 中的 OpenCode...",
"wsl.onboarding.updateOpencodeIn": "更新 {{distro}} 中的 OpenCode。",
"wsl.onboarding.updateOpencode": "更新 OpenCode",
"wsl.onboarding.opencodeReadyIn": "{{distro}} 中的 OpenCode 已就緒。",
"wsl.onboarding.opencodeReady": "OpenCode 已就緒。",
"wsl.onboarding.installOpencodeIn": "在 {{distro}} 中安裝 OpenCode。",
"wsl.onboarding.installOpencode": "安裝 OpenCode",
"wsl.onboarding.chooseDistroFirst": "請先選擇發行版。",
"wsl.onboarding.loadFailed": "無法載入 WSL 狀態。",
"wsl.onboarding.loading": "載入中...",
"wsl.onboarding.installedDistros": "已安裝的發行版",
"wsl.onboarding.checkAgain": "再次檢查",
"wsl.onboarding.distroStatus.ready": "就緒",
"wsl.onboarding.distroStatus.checking": "檢查中...",
"wsl.onboarding.distroStatus.opencodeMissing": "未安裝 OpenCode",
"wsl.onboarding.distroStatus.missingTools": "缺少 bash、curl",
"wsl.onboarding.distroStatus.unsupported": "不支援 · 請使用 WSL 2",
"wsl.onboarding.needAnotherDistro": "需要其他發行版?",
"wsl.onboarding.needAnotherDistroHint": "從 WSL 目錄安裝 Linux 發行版",
"wsl.onboarding.wslNotInstalled.title": "未安裝 WSL",
"wsl.onboarding.wslNotInstalled.description": "新增 WSL 伺服器前,必須先安裝 WSLWindows Subsystem for Linux",
"wsl.onboarding.wslUnavailable.title": "WSL 無法使用",
"wsl.onboarding.wslUnavailable.description": "OpenCode 無法確認此電腦上的 WSL。",
"wsl.onboarding.installWsl": "安裝 WSL",
"wsl.onboarding.windowsRestartRequired": "重新啟動 Windows 以完成 WSL 安裝,然後重新開啟 OpenCode。",
"wsl.onboarding.next": "下一步",
"wsl.onboarding.refresh": "重新整理",
"wsl.onboarding.allDistrosAdded": "所有已安裝的發行版都已新增。",
"wsl.onboarding.noDistros": "尚未偵測到發行版。",
"wsl.onboarding.install": "安裝",
"wsl.onboarding.installing": "安裝中...",
"wsl.onboarding.installDistro": "安裝發行版",
"wsl.onboarding.searchDistros": "搜尋發行版",
"wsl.onboarding.wsl2Required": "需要 WSL 2。",
"wsl.onboarding.toolsRequired": "此發行版需要 bash 和 curl。",
"wsl.onboarding.openTerminal": "開啟終端機",
"wsl.onboarding.path": "路徑: {{path}}",
"wsl.onboarding.notFound": "找不到",
"wsl.onboarding.version": "版本: {{version}}",
"wsl.onboarding.unknown": "未知",
"wsl.onboarding.desktopVersion": "桌面版 {{version}}",
"wsl.onboarding.versionMismatch": "已安裝版本與桌面應用程式版本不符。",
"wsl.onboarding.adding": "新增中...",
"dialog.project.edit.title": "編輯專案",
"dialog.project.edit.name": "名稱",
@@ -402,6 +481,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -449,10 +529,12 @@ export const dict = {
"error.page.title": "出了點問題",
"error.page.description": "載入應用程式時發生錯誤。",
"error.page.description.localServerStartup": "啟動本機伺服器時發生錯誤。",
"error.page.details.label": "錯誤詳情",
"error.page.action.restart": "重新啟動",
"error.page.action.report": "回報錯誤",
"error.page.action.reported": "已回報錯誤",
"error.page.action.exportLogs": "匯出日誌",
"error.page.action.checking": "檢查中...",
"error.page.action.checkUpdates": "檢查更新",
"error.page.action.updateTo": "更新到 {{version}}",
@@ -498,17 +580,39 @@ export const dict = {
"home.recentProjects": "最近專案",
"home.empty.title": "沒有最近專案",
"home.empty.description": "透過開啟本地專案開始使用",
"home.title": "首頁",
"home.projects": "專案",
"home.project.add": "新增專案",
"home.recentlyClosed": "最近關閉",
"home.server.collapse": "收合伺服器專案",
"home.server.expand": "展開伺服器專案",
"home.sessions.search.placeholder": "搜尋工作階段",
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜尋工作階段",
"home.sessions.search.sessions": "工作階段",
"home.sessions.search.noResults": "找不到符合 {{query}} 的工作階段",
"home.sessions.empty": "這裡還沒有內容",
"home.sessions.empty.description": "建立工作階段以開始使用",
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早",
"session.tab.session": "工作階段",
"session.tab.review": "審查",
"session.tab.context": "上下文",
"session.tab.unknown": "未知的工作階段",
"session.panel.reviewAndFiles": "審查與檔案",
"session.error.notFound": "找不到此工作階段",
"session.error.notFound.description": "此分頁指向的工作階段已不存在於此伺服器。",
"session.error.notFound.closeTab": "關閉分頁",
"session.error.serverConnection": "無法連線至此伺服器",
"session.review.filesChanged": "{{count}} 個檔案變更",
"session.review.change.one": "變更",
"session.review.change.other": "變更",
"session.review.loadingChanges": "正在載入變更...",
"session.review.empty": "此工作階段暫無變更",
"session.review.noChanges": "沒有變更",
"session.review.noUncommittedChanges": "尚無未提交的變更",
"session.review.noBranchChanges": "尚無分支變更",
"session.review.noVcs": "未偵測到 Git 版本控制系統,無法顯示變更",
"session.review.noSnapshot": "設定中已停用快照追蹤,因此無法使用工作階段變更",
"session.files.selectToOpen": "選取要開啟的檔案",
@@ -525,6 +629,10 @@ export const dict = {
"session.todo.title": "待辦事項",
"session.todo.collapse": "折疊",
"session.todo.expand": "展開",
"session.question.minimize": "最小化問題",
"session.question.restore": "還原問題",
"session.question.pending.one": "{{count}} 個待處理問題",
"session.question.pending.other": "{{count}} 個待處理問題",
"session.followupDock.summary.one": "{{count}} 則佇列訊息",
"session.followupDock.summary.other": "{{count}} 則佇列訊息",
"session.followupDock.sendNow": "立即傳送",
@@ -538,9 +646,16 @@ export const dict = {
"session.revertDock.restore": "還原訊息",
"session.new.title": "建構任何東西",
"session.new.project.new": "新專案",
"session.new.project.search": "搜尋專案",
"session.new.project.add": "新增專案",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
"session.new.worktree.create": "建立新的 worktree",
"session.new.workspace.runIn": "工作階段執行位置",
"session.new.workspace.triggerLocal": "本機",
"session.new.workspace.local": "本機儲存庫",
"session.new.workspace.existing": "工作區…",
"session.new.lastModified": "最後修改",
"session.header.search.placeholder": "搜尋 {{project}}",
@@ -608,6 +723,8 @@ export const dict = {
"sidebar.project.recentSessions": "最近工作階段",
"sidebar.project.viewAllSessions": "查看全部工作階段",
"sidebar.project.clearNotifications": "清除通知",
"sidebar.empty.title": "未開啟任何專案",
"sidebar.empty.description": "開啟專案以開始使用",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "桌面",
@@ -619,6 +736,7 @@ export const dict = {
"settings.desktop.wsl.description": "在 Windows 上的 WSL 中執行 OpenCode 伺服器。",
"settings.general.section.appearance": "外觀",
"settings.general.section.advanced": "進階",
"settings.general.section.notifications": "系統通知",
"settings.general.section.updates": "更新",
"settings.general.section.sounds": "音效",
@@ -627,6 +745,10 @@ export const dict = {
"settings.general.row.language.title": "語言",
"settings.general.row.language.description": "變更 OpenCode 的顯示語言",
"settings.general.row.shell.title": "終端機 Shell",
"settings.general.row.shell.description": "選擇終端機使用的 Shell。相容的 Shell 也會用於代理程式工具呼叫。",
"settings.general.row.shell.autoDefault": "自動(預設)",
"settings.general.row.shell.terminalOnly": "僅限終端機",
"settings.general.row.appearance.title": "外觀",
"settings.general.row.appearance.description": "自訂 OpenCode 在你的裝置上的外觀",
"settings.general.row.colorScheme.title": "配色方案",
@@ -635,14 +757,28 @@ export const dict = {
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
"settings.general.row.font.title": "程式碼字型",
"settings.general.row.font.description": "自訂程式碼區塊使用的字型",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "終端機字型",
"settings.general.row.terminalFont.description": "自訂終端機使用的字型",
"settings.general.row.uiFont.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為",
"settings.general.row.followup.description": "選擇後續追問提示是立即引導還是進入佇列等待",
"settings.general.row.followup.option.queue": "佇列",
"settings.general.row.followup.option.steer": "引導",
"settings.general.row.showFileTree.title": "檔案樹",
"settings.general.row.showFileTree.description": "在工作階段中顯示檔案樹面板",
"settings.general.row.showNavigation.title": "導覽控制項",
"settings.general.row.showNavigation.description": "在桌面標題列中顯示上一頁和下一頁按鈕",
"settings.general.row.showSearch.title": "命令面板",
"settings.general.row.showSearch.description": "在標題列中顯示搜尋和命令面板按鈕",
"settings.general.row.showTerminal.title": "終端機",
"settings.general.row.showTerminal.description": "在桌面標題列中顯示終端機按鈕",
"settings.general.row.showStatus.title": "伺服器狀態",
"settings.general.row.showStatus.description": "在標題列中顯示伺服器狀態按鈕",
"settings.general.row.mobileTitlebarBottom.title": "底部導覽",
"settings.general.row.mobileTitlebarBottom.description": "在行動裝置上將標題列和工作階段分頁置於畫面底部",
"settings.general.row.showCustomAgents.title": "自訂代理程式",
"settings.general.row.showCustomAgents.description": "在輸入區顯示代理程式選擇器",
"settings.general.row.reasoningSummaries.title": "顯示推理摘要",
"settings.general.row.reasoningSummaries.description": "在時間軸中顯示模型推理摘要",
@@ -650,6 +786,10 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
"settings.general.row.newLayoutDesigns.title": "新版面與設計",
"settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面",
"settings.general.row.pinchZoom.title": "雙指縮放",
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
@@ -663,6 +803,8 @@ export const dict = {
"settings.updates.row.check.description": "手動檢查更新並在有更新時安裝",
"settings.updates.action.checkNow": "立即檢查",
"settings.updates.action.checking": "檢查中...",
"settings.updates.action.downloading": "下載中...",
"settings.updates.action.installing": "安裝中...",
"settings.updates.toast.latest.title": "已是最新版本",
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
+40 -2
View File
@@ -224,7 +224,26 @@
[data-slot="model-selector-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
height: 12px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100%
);
}
[data-slot="manage-models-scroll"]::before {
@@ -239,7 +258,26 @@
[data-slot="model-selector-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
height: 12px;
background: linear-gradient(
0deg,
color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100%
);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
+6
View File
@@ -1,4 +1,10 @@
export { AppBaseProviders, AppInterface } from "./app"
export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk"
export { useServerSync } from "./context/server-sync"
export { useServer } from "./context/server"
export { useTabs } from "./context/tabs"
export { useProviders } from "./hooks/use-providers"
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"
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => {
test("requires only the platform primary modifier", () => {
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
})
})
@@ -0,0 +1,11 @@
export function shouldOpenSessionInBackground(input: {
mac: boolean
meta: boolean
ctrl: boolean
shift: boolean
alt: boolean
}) {
if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta
}
+34 -13
View File
@@ -35,7 +35,7 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useSettingsCommand } from "@/components/settings-dialog"
import { useSettingsDialog } from "@/components/settings-dialog"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
@@ -66,6 +66,7 @@ import { Persist, persisted } from "@/utils/persist"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { archiveHomeSession } from "./home-session-archive"
import { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64
@@ -238,6 +239,20 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
// background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
})
}
type OpenSessionOptions = { background?: boolean }
export function NewHome() {
const sync = useServerSync()
const layout = useLayout()
@@ -252,7 +267,7 @@ export function NewHome() {
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
const openSettings = useSettingsCommand()
const openSettings = useSettingsDialog()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@@ -377,9 +392,11 @@ export function NewHome() {
setState("searchFocused", false)
}
function selectSearchSession(session: Session) {
openSession(session)
closeSearch()
function selectSearchSession(session: Session, options?: OpenSessionOptions) {
openSession(session, options)
// Background opens keep the search visible so several results can be
// opened in a row.
if (!options?.background) closeSearch()
}
command.register("home", () => [
@@ -464,13 +481,17 @@ export function NewHome() {
.forEach((directory) => state.project.markViewed(directory))
}
function openSession(session: Session) {
function openSession(session: Session, options?: OpenSessionOptions) {
const project = projectForSession(session, projects(), projectByID())
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
return
}
ctx.projects.touch(directory)
startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
@@ -1130,7 +1151,7 @@ function HomeSessionSearch(props: {
onInput: (value: string) => void
onFocus: () => void
onClose: () => void
onSelect: (session: Session) => void
onSelect: (session: Session, options?: OpenSessionOptions) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ active: "" })
@@ -1244,7 +1265,7 @@ function HomeSessionSearch(props: {
server={props.server}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
onSelect={(session, options) => props.onSelect(session, options)}
/>
)}
</For>
@@ -1257,7 +1278,7 @@ function HomeSessionSearch(props: {
</div>
</div>
</Show>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={input}
@@ -1324,7 +1345,7 @@ function HomeSessionSearchResultRow(props: {
server: ServerConnection.Key
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
onSelect: (session: Session, options?: OpenSessionOptions) => void
}) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName && props.record.projectName
@@ -1345,7 +1366,7 @@ function HomeSessionSearchResultRow(props: {
group: !!showProjectName(),
}}
onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)}
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
>
<HomeSessionLeading
project={props.record.project}
@@ -1389,7 +1410,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
openSession: (session: Session) => void
openSession: (session: Session, options?: OpenSessionOptions) => void
archiveSession: (session: Session) => Promise<void>
}) {
const language = useLanguage()
@@ -1405,7 +1426,7 @@ function HomeSessionRow(props: {
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
onClick={() => props.openSession(props.record.session)}
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
>
<HomeSessionLeading
project={props.record.project}
+2
View File
@@ -6,11 +6,13 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useSettingsCommand } from "@/components/settings-dialog"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
useSettingsCommand()
createEffect(() => setV2Toast(true))
+2 -2
View File
@@ -1095,9 +1095,9 @@ export default function LegacyLayout(props: ParentProps) {
function connectProvider() {
const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
void import("@/components/dialog-connect-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
void dialog.show(() => <x.DialogConnectProvider />)
})
}
@@ -2,8 +2,8 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
@@ -26,15 +26,20 @@ export function useSessionTabAvatarState(
return !permission.autoResponds(item, directory())
})
})
const unread = createMemo(() => {
if (hasPermissions()) return true
if (!connection()) return false
return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0
const hasQuestions = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo(
() => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0,
)
const loading = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
if (hasPermissions()) return false
if (needsAttention()) return false
return serverSync.session.data.session_working(sessionId())
})
return { unread, loading }

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