Compare commits

...

235 Commits

Author SHA1 Message Date
Adam
2d65db95af fix(app): startup homepage flash 2026-03-13 09:50:37 -05:00
David Hill
f5f07310e0 fix(app): sidebar spacing + session list spinner transition (#17355) 2026-03-13 09:19:02 -05:00
Adam
c9e9dbeee1 fix(app): terminal cloning without retry (#17354) 2026-03-13 08:56:48 -05:00
Adam
b88b323049 fix(app): scroll falls behind prompt input 2026-03-13 08:39:42 -05:00
Adam
6653f868ae fix(app): tooltip quirks 2026-03-13 08:38:32 -05:00
Adam
af29d91dca fix(app): todo spacing 2026-03-13 07:43:50 -05:00
Adam
1a3735b619 fix(app): better optimistic prompt submit (#17337) 2026-03-13 07:38:03 -05:00
Shoubhit Dash
d4ae13f2a0 fix(opencode): serialize config bun installs (#17342) 2026-03-13 17:58:00 +05:30
Adam
f4804dac85 fix(app): oc-2 went too dark 2026-03-13 07:25:42 -05:00
Adam
843f188aaa fix(app): support text attachments (#17335) 2026-03-13 06:58:24 -05:00
Adam
05cb3c87ca chore(app): i18n sync (#17283) 2026-03-13 06:48:38 -05:00
Adam
270cb0b8b4 chore: cleanup (#17284) 2026-03-13 06:27:58 -05:00
Shoubhit Dash
46ba9c8170 perf(app): use cursor session history loading (#17329) 2026-03-13 16:43:41 +05:30
David Hill
80f91d3fd9 Remove prompt mode toggle (#17216) 2026-03-13 05:38:34 -05:00
opencode-agent[bot]
a564231caf chore: generate 2026-03-13 10:19:52 +00:00
Shoubhit Dash
9457493696 perf(server): paginate session history (#17134) 2026-03-13 15:48:43 +05:30
Adam
ff748b82ca fix(app): simplify themes (#17274) 2026-03-13 10:12:58 +00:00
Frank
9fafa57562 go: upi pay 2026-03-13 03:24:33 -04:00
Aiden Cline
f8475649da chore: cleanup migrate from global code (#17292) 2026-03-12 23:54:11 -05:00
Michael Dwan
b94e110a4c fix(opencode): sessions lost after git init in existing project (#16814)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-12 23:18:59 -05:00
Luke Parker
f0bba10b12 fix(e2e): fail fast on config dependency installs (#17280) 2026-03-13 14:08:51 +10:00
Adam
d961981e25 fix(app): list item background colors 2026-03-12 22:23:51 -05:00
Adam
5576662200 feat(app): missing themes (#17275) 2026-03-12 22:21:02 -05:00
Tom Ryder
4a2a046d79 fix: filter empty content blocks for Bedrock provider (#14586) 2026-03-12 22:13:09 -05:00
opencode-agent[bot]
8f8c74cfb8 chore: generate 2026-03-13 02:33:45 +00:00
Kit Langton
092f654f63 fix(cli): hide console command from help output (#17277) 2026-03-12 22:22:16 -04:00
Luke Parker
96b1d8f639 fix(app): stabilize todo dock e2e with composer probe (#17267) 2026-03-13 12:21:50 +10:00
opencode-agent[bot]
dcb17c6a67 chore: generate 2026-03-13 02:10:27 +00:00
Kit Langton
dd68b85f58 refactor(provider): effectify ProviderAuthService (#17227) 2026-03-13 02:08:37 +00:00
Brendan Allan
84df96eaef desktop: multi-window support in electron (#17155) 2026-03-13 09:18:27 +08:00
Kit Langton
d9dd33aeeb feat(cli): add console account subcommands (#17265) 2026-03-13 00:56:40 +00:00
Kit Langton
0a281c7390 refactor(auth): effectify AuthService (#17212) 2026-03-12 20:43:24 -04:00
Aiden Cline
3016efba47 tweak: rm openrouter warning (#17259) 2026-03-12 19:42:31 -05:00
Luke Parker
3998df8112 fix(app): increase CI e2e workers (#17263) 2026-03-13 10:15:34 +10:00
Kit Langton
7066e2a25e reorder provider list in providers login (#17262) 2026-03-13 00:09:30 +00:00
Adam
c173988aaa feat(app): interruption state 2026-03-12 19:07:23 -05:00
Luke Parker
268855dc5a fix(ci): keep test runs on dev (#17260) 2026-03-13 09:54:34 +10:00
opencode
bfb736e94a release: v1.2.25 2026-03-12 23:34:11 +00:00
Frank
df8464f89c zen: handle cache key 2026-03-12 19:10:58 -04:00
Adam
3ea387f364 fix(app): sidebar re-rendering too often 2026-03-12 16:27:15 -05:00
Adam
9d3c42c8c4 fix(app): task error state 2026-03-12 16:25:49 -05:00
Adam
f2cad046e6 fix(app): message loading 2026-03-12 16:25:48 -05:00
Aiden Cline
d722026a8d fix: if server password exists, use basic auth for plugin client by default (#17213) 2026-03-12 15:41:46 -05:00
Adam
42a5af6c8f feat(app): follow-up behavior (#17233) 2026-03-12 20:17:36 +00:00
Adam
f0542fae7a fix(app): optimistic revert/restore 2026-03-12 15:04:27 -05:00
Adam
02c75821a8 feat(app): AMOLED theme 2026-03-12 14:11:22 -05:00
Adam
3ba9ab2c0a fix(app): not loading message nav 2026-03-12 13:33:17 -05:00
David Hill
184732fc20 fix(app): titlebar cleanup (#17206) 2026-03-12 18:26:50 +00:00
Frank
b66222baf7 zen: fix nemotron issue 2026-03-12 14:17:36 -04:00
Adam
dce7eceb28 chore: cleanup (#17197) 2026-03-12 11:32:05 -05:00
Adam
0e077f7483 feat: session load perf (#17186) 2026-03-12 11:31:52 -05:00
Adam
776e7a9c15 feat(app): better themes (#16889) 2026-03-12 10:35:23 -05:00
opencode-agent[bot]
c455d41876 chore: update nix node_modules hashes 2026-03-12 15:07:54 +00:00
Aiden Cline
a776a3ee12 fix: non openai azure models that use completions endpoints (#17128) 2026-03-12 10:05:00 -05:00
Kit Langton
64fb9233bf refactor(import): use .parse() at boundaries instead of manual .make() (#17106) 2026-03-12 10:52:20 -04:00
opencode-agent[bot]
3533f33ecb chore: generate 2026-03-12 14:49:22 +00:00
Kit Langton
1cb7df7159 refactor(provider): flow branded ProviderID/ModelID through internal signatures (#17182) 2026-03-12 14:48:17 +00:00
Wang Siyuan
a4f8d66a9b docs: clarify subagent session navigation keybinds (#16455) 2026-03-12 09:45:46 -05:00
Adam
12efbbfa4c chore: cleanup (#17184) 2026-03-12 08:52:51 -05:00
max tomashevsky
13402529ce fix(web): fix broken mobile sidebar (in workflows mode) sizing issue by adding flex-1 (#17055) 2026-03-12 08:46:41 -05:00
Adam
fc678ef36c fix(app): terminal animation 2026-03-12 08:43:25 -05:00
Adam
03cd891ea9 chore: cleanup 2026-03-12 08:41:22 -05:00
opencode-agent[bot]
6314d741e7 chore: generate 2026-03-12 13:28:46 +00:00
Kit Langton
c45467964c feat(id): brand ProviderID and ModelID (#17110) 2026-03-12 09:27:52 -04:00
Adam
2eeba53b07 fix(app): sidebar quirks 2026-03-12 07:51:31 -05:00
Adam
d4107d51f1 chore: cleanup (#17115) 2026-03-12 12:26:43 +00:00
opencode-agent[bot]
d8fbe0af01 chore: update nix node_modules hashes 2026-03-12 08:26:49 +00:00
Brendan Allan
b76ead3fe8 refactor(desktop): rework default server initialization and connection handling (#16965) 2026-03-12 08:10:52 +00:00
opencode-agent[bot]
51835ecf90 chore: generate 2026-03-12 07:36:35 +00:00
Luke Parker
328c6de80d Fix terminal e2e flakiness with a real terminal driver (#17144) 2026-03-12 17:35:26 +10:00
OpeOginni
c9c0318e0e fix(desktop): set default WebSocket username and prevent repeated calling of terminal spawn properly closing the terminal (#17061) 2026-03-12 14:48:44 +08:00
Luke Parker
d481f64bde fix(electron): theme Windows titlebar overlay (#16843)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-03-12 16:38:56 +10:00
Luke Parker
54e7baa6cf fix(desktop-electron): fix resource loading under file:// protocol (#17125) 2026-03-12 12:19:44 +08:00
opencode-agent[bot]
1d7fcd40b4 chore: generate 2026-03-12 03:56:04 +00:00
Luke Parker
db7bafe917 fix(app): guard comment accessor in message timeline (#17126) 2026-03-12 13:55:16 +10:00
Dax Raad
b1ef501207 Merge remote-tracking branch 'origin/dev' into dev 2026-03-11 23:24:38 -04:00
Dax Raad
9fb12a906e core: remove external sourcemap generation to reduce build artifacts 2026-03-11 23:24:26 -04:00
Luke Parker
fafbc29316 fix(ci): use dynamic bun cache path for cross-platform support (#17120) 2026-03-12 03:19:28 +00:00
opencode-agent[bot]
7b0def4b81 chore: generate 2026-03-12 02:04:26 +00:00
Luke Parker
1d9c83b576 fix(e2e): re-focus prompt after terminal opens in slash-terminal test (#17113) 2026-03-12 12:03:38 +10:00
opencode-agent[bot]
2c825c3223 chore: generate 2026-03-12 01:50:44 +00:00
Kit Langton
2a4dedc210 feat(id): brand PermissionID, PtyID, QuestionID, and ToolID (#17042) 2026-03-12 01:49:57 +00:00
opencode-agent[bot]
b0bca6342e chore: generate 2026-03-12 00:26:05 +00:00
Luke Parker
547eb7676d feat(windows): add arm64 release targets for cli and desktop (#16696) 2026-03-12 00:25:09 +00:00
opencode-agent[bot]
83f083ee0d chore: generate 2026-03-11 23:41:43 +00:00
Kit Langton
090f636354 feat(id): brand PartID through Drizzle and Zod schemas (#16966) 2026-03-11 23:40:50 +00:00
opencode-agent[bot]
d26c6f80e1 chore: generate 2026-03-11 23:31:07 +00:00
Kit Langton
16a6d6feba feat(id): brand WorkspaceID through Drizzle and Zod schemas (#16964) 2026-03-11 23:30:17 +00:00
John Mylchreest
f1c3a44190 fix: resolve symlinks in Instance cache to prevent duplicate contexts (#16651)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-11 23:26:54 +00:00
opencode-agent[bot]
34fa5de9c5 chore: generate 2026-03-11 23:17:42 +00:00
Kit Langton
cb67465675 feat(id): brand SessionID through Drizzle and Zod schemas (#16953) 2026-03-11 23:16:56 +00:00
Frank
4e73473119 wip: zen 2026-03-11 19:00:05 -04:00
Frank
cc18fa599c wip: zen 2026-03-11 18:50:49 -04:00
Frank
aa81c1c4cb docs: go pricing 2026-03-11 18:09:41 -04:00
Frank
8569fc1f0e docs: zen update models 2026-03-11 18:09:41 -04:00
Frank
78de287bcc wip: zen 2026-03-11 18:09:41 -04:00
Frank
bbc7052c7a go: dashboard design 2026-03-11 18:09:41 -04:00
Frank
502d6db6d0 go: first month discount 2026-03-11 18:09:41 -04:00
Frank
0b0ad5de99 zen: update discount copy on lander 2026-03-11 18:09:41 -04:00
Frank
9e6c4a01aa zen: add alipay for adding balance 2026-03-11 18:09:41 -04:00
Frank
4a81df190c zen: add alipay for go sub 2026-03-11 18:09:41 -04:00
Frank
75cae81f75 zen: add Go page 2026-03-11 18:09:41 -04:00
Frank
ed3bb3ea8f zen: add usage section 2026-03-11 18:09:40 -04:00
Frank
fac23a1afc zen: update usage graph on landing page 2026-03-11 18:09:40 -04:00
Frank
f89696509e zen: update header 2026-03-11 18:09:40 -04:00
Dax Raad
604ab1bde1 core: restore plugin serverUrl getter so plugins can connect to local server 2026-03-11 17:41:51 -04:00
Adam
fbd9b7cf4f feat(app): restore to message and fork session (#17092) 2026-03-11 21:34:48 +00:00
Adam
58f45ae22b chore: skip test 2026-03-11 16:21:04 -05:00
Noam Bressler
440405dbdd fix: re-enable snapshot in acp (#14918) 2026-03-11 16:18:40 -05:00
Adam
a1cda29012 chore: fix test 2026-03-11 16:11:02 -05:00
Aiden Cline
f96e2d4222 tweak: adjust skill presentation to be a little less token heavy (#17098) 2026-03-11 16:03:15 -05:00
Adam
387ab78bf6 chore: fix test 2026-03-11 16:02:11 -05:00
Kit Langton
dbc00aa8e0 feat(id): brand ProjectID through Drizzle and Zod schemas (#16948) 2026-03-11 16:44:26 -04:00
Adam
c37f7b9d99 fix(app): todos not clearing 2026-03-11 14:42:34 -05:00
Chris Yang
cf7ca9b2f7 fix(app): skip editor reconcile during IME composition (#17041) 2026-03-11 13:40:06 -05:00
Kit Langton
981c7b9e37 refactor(account): tighten effect-based account flows (#17072) 2026-03-11 18:18:58 +00:00
Johannes Loher
2aae0d3493 fix(core): read stdout and stderr in PackageRegistry.info before waiting for the process to exit (#16998) 2026-03-11 13:10:45 -05:00
Adam
bcc0d19867 chore(app): simplify review pane (#17066) 2026-03-11 12:24:51 -05:00
xinxin
9c585bb58b docs(providers): clarify npm choice for chat vs responses APIs (#16974)
Co-authored-by: wangxinxin <xinxin.wang@pharmbrain.com>
2026-03-11 10:35:16 -05:00
Aiden Cline
0f6bc8ae71 tweak: adjust way skills are presented to agent to increase likelyhood of skill invocations. (#17053) 2026-03-11 10:24:55 -05:00
Shoubhit Dash
7291e28273 perf(app): trim session render work (#16987) 2026-03-11 18:19:17 +05:30
Filip
db57fe6193 fix(app): make error tool card respect settings (#17005) 2026-03-11 14:52:33 +05:30
Brendan Allan
802416639b ci: setup node in tauri build 2026-03-11 16:09:17 +08:00
opencode-agent[bot]
7ec398d855 chore: generate 2026-03-11 03:34:02 +00:00
Luke Parker
4ab35d2c5c fix(electron): hide Windows background consoles (#16842)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-11 13:33:06 +10:00
SOUMITRA-SAHA
b4ae030fc2 fix: add GOOGLE_VERTEX_LOCATION env var support for Vertex AI (#16922)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-10 22:32:39 -05:00
Jack
0843964eb3 feat(web): use Feishu for Chinese community links (#16908)
Co-authored-by: Frank <frank@anoma.ly>
2026-03-11 11:07:13 +08:00
Kit Langton
a1b06d63c9 fix(account): resilient orgs fetch (#16944) 2026-03-11 00:39:07 +00:00
Dax Raad
1b6820bab5 sync 2026-03-10 20:13:56 -04:00
Adam
89bf199c07 chore(app): fix tests 2026-03-10 19:03:44 -05:00
Aiden Cline
5acfdd1c5d chore: kill old copilot 403 message that was used for old plugin migration (#16904) 2026-03-10 16:20:41 -05:00
Dax Raad
556703f8ab ci: cancel duplicate workflow runs and add read permissions
- Add concurrency settings to cancel outdated runs when new commits are pushed
- Add contents: read permission for security hardening
- Remove redundant required job that checked test results
2026-03-10 17:17:11 -04:00
Frank
6b9f8fb9b3 zen: raise limit 2026-03-10 15:22:02 -04:00
David Hill
f77e5cf8fb feat(ui): restyle Card and improve tool error cards (#16888)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-10 13:52:32 -05:00
Frank
e6cdc21f2d zen: raise limit 2026-03-10 14:40:18 -04:00
Dax Raad
1fe8d4d7ad ci: exclude draft PRs from beta labeling process to prevent unfinished work from being included in releases 2026-03-10 14:08:55 -04:00
Dax Raad
e44320980d ci: install setuptools to prevent Python distutils errors during dependency installation 2026-03-10 14:08:38 -04:00
Adam
f5d7fe3072 chore: cleanup 2026-03-10 13:00:14 -05:00
Adam
835a27cf51 fix(app): terminal jank 2026-03-10 13:00:14 -05:00
Adam
85afaaa13d fix(app): terminal focus issues and jank 2026-03-10 13:00:14 -05:00
opencode-agent[bot]
490615169e chore: update nix node_modules hashes 2026-03-10 17:14:55 +00:00
Dax
bb232247d0 Fix ESM imports for @opencode-ai/plugin (#16916) 2026-03-10 13:11:36 -04:00
opencode-agent[bot]
94c128f73b chore: generate 2026-03-10 16:56:30 +00:00
Dax
613562f504 core: make account login upgrades safe while adding multi-account workspace auth (#15487)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:53:47 -04:00
Adam
9c4325bcf8 fix(core): don't permit access to system directories (#16891) 2026-03-10 11:32:05 -05:00
Aiden Cline
ad08fd57df chore: rekram1-node is no longer on vacation (#16905) 2026-03-10 10:27:04 -05:00
opencode-agent[bot]
54ba59d3e1 chore: generate 2026-03-10 15:14:46 +00:00
James Long
a4330a225d feat(core): allow passing workspaceID into session create endpoint (#16798) 2026-03-10 11:12:51 -04:00
James Long
69ddc91c35 fix(core): a chunk timeout when processing llm stream (#16366) 2026-03-10 11:12:14 -04:00
James Long
4c4aed5a87 fix(core): make worktrees read the project id from local workspace (#16795) 2026-03-10 11:11:28 -04:00
opencode-agent[bot]
5a40158abf chore: generate 2026-03-10 15:07:35 +00:00
Jérôme Benoit
4dce485854 fix(opencode): add thinking variants support for SAP AI provider (#14958)
Co-authored-by: Test <test@test.com>
Co-authored-by: Stephen Collings <stevoland@gmail.com>
2026-03-10 10:05:45 -05:00
Adam
5ec5d1dace chore(app): debug window 2026-03-10 07:05:54 -05:00
opencode-agent[bot]
d2c765e2b3 chore: generate 2026-03-10 11:01:22 +00:00
bhaktatejas922
d036c57d59 docs: update opencode-morph-plugin in all language ecosystem pages (#16869) 2026-03-10 06:00:13 -05:00
opencode-agent[bot]
e7493e2204 chore: update nix node_modules hashes 2026-03-10 10:16:15 +00:00
Sebastian
3500bf64b8 upgrade opentui to v0.1.87 (#16772) 2026-03-10 11:03:05 +01:00
opencode-agent[bot]
4f982ddb94 chore: generate 2026-03-10 02:02:18 +00:00
adam jones
ff3bb7424d fix(mcp): fix OAuth auto-connect failing on first connection (#15547)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 21:01:19 -05:00
Dax Raad
89d6f60d25 refactor(server): extract createApp function for server initialization
- Replace Server.App() with Server.Default() for internal server access
- Extract server app creation into Server.createApp(opts) for testability
- Move CORS whitelist from module-level variable to function parameter
- Update all tests to use Server.Default() instead of Server.App()
2026-03-09 17:13:52 -04:00
Adam
ee18c9976e chore(app): dev stats 2026-03-09 15:57:24 -05:00
Adam
794532928f fix(app): terminal state corruption 2026-03-09 15:28:35 -05:00
Adam
7b773c65ec chore: cleanup 2026-03-09 15:28:35 -05:00
Adam
e53aa79dc6 chore: cleanup 2026-03-09 15:28:35 -05:00
opencode-agent[bot]
d9a97249c0 chore: generate 2026-03-09 20:15:31 +00:00
James Long
86cef16940 fix(core): put workspace routing behind OPENCODE_EXPERIMENTAL_WORKSPACES flag (#16775) 2026-03-09 16:14:19 -04:00
opencode-agent[bot]
ce38997c76 chore: update nix node_modules hashes 2026-03-09 19:51:58 +00:00
opencode-agent[bot]
7e10c728d4 chore: update nix node_modules hashes 2026-03-09 19:49:01 +00:00
bhaktatejas922
3627c67cf2 docs: update opencode-morph-fast-apply to opencode-morph-plugin in ecosystem (#16634) 2026-03-09 14:39:06 -05:00
opencode-agent[bot]
2518fd81f6 chore: generate 2026-03-09 19:31:33 +00:00
Dax Raad
39ef7fc90e Merge remote-tracking branch 'origin/dev' into dev 2026-03-09 15:30:15 -04:00
Dax Raad
37ae0a4051 refactor: replace bun semver with npm semver package 2026-03-09 15:29:55 -04:00
Kyle Altendorf
b312928e9f fix(tui): wait for model store before auto-submitting --prompt (#7476) 2026-03-09 14:22:38 -05:00
Dax
2f2856e20a refactor(opencode): replace Bun shell in core flows (#16286) 2026-03-09 15:19:50 -04:00
Dax Raad
831eb6881b refactor: change pathToFileURL imports from bun to url module 2026-03-09 14:52:25 -04:00
James Long
f20ee2fad2 fix(tui): handle error when creating a session (#16767) 2026-03-09 12:13:32 -04:00
Stephen Collings
8b9710e56c fix: Multiple jdtls LSPs eating memory in java monorepos (#12123) 2026-03-09 16:09:43 +00:00
opencode
c6262f9d40 release: v1.2.24 2026-03-09 16:09:34 +00:00
Adam
b749fa90f2 fix(app): scroll jitter/loop 2026-03-09 10:44:02 -05:00
Dax Raad
8a51cbd253 core: prevent accidental edits to migration files by restricting agent access 2026-03-09 11:25:58 -04:00
David Hill
399b8f0701 fix(app): session title turn spinner (#16764) 2026-03-09 09:46:15 -05:00
Filip
3742e42fdf fix(app): dismiss toast notifications when questions or permissions a… (#16758) 2026-03-09 09:36:57 -05:00
Karan Handa
0388ec6862 fix(storybook): add ci build workflow (#16760) 2026-03-09 09:33:19 -05:00
James Long
366b8a8034 feat(tui): add initial support for workspaces into the tui (#16230) 2026-03-09 10:28:04 -04:00
Armin Pašalić
ef9bc4ec9e feat(gitlab): send context-1m-2025-08-07 beta header to enable 1M context window (#16153)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 09:22:00 -05:00
Jack
5838b58913 add copilot gpt-5.4 xhigh support (#16294) 2026-03-09 22:07:12 +08:00
opencode
2712244ad3 release: v1.2.23 2026-03-09 13:50:43 +00:00
Adam
6388cbaf92 fix(app): remove oc-1 theme 2026-03-09 08:25:41 -05:00
David Hill
5cc61e1b53 tui: fix sidebar workspace container sizing by adding box-border class to prevent content overflow issues 2026-03-09 13:05:43 +00:00
Adam
0243be86a7 fix(app): don't animate review panel in/out 2026-03-09 07:49:11 -05:00
opencode-agent[bot]
9154cd64e7 chore: update nix node_modules hashes 2026-03-09 12:46:47 +00:00
Adam
c71d1bde5e revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745) 2026-03-09 07:36:39 -05:00
Luke Parker
f27ef595f6 fix(app): sanitize workspace store filenames on Windows (#16703) 2026-03-09 20:26:53 +10:00
Yihui Khuu
34328828ae fix(app): fix issue with scroll jumping when pressing escape in comment text area (#15374) 2026-03-09 15:29:24 +05:30
Eric Clemmons
18fb19da3b fix(opencode): pass missing auth headers in run --attach (#16097)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-09 07:32:13 +00:00
opencode-agent[bot]
849e1ac543 docs(i18n): sync locale docs from english changes 2026-03-09 02:08:46 +00:00
Ariane Emory
656a8d8f55 docs: add session_child_first keybinding to documentation (#16631) 2026-03-08 21:03:52 -05:00
Adam
b976f339e8 feat(app): generate color palettes (#16232) 2026-03-08 19:28:58 -05:00
Dax Raad
7d7837e5b6 disable fallback to free nano for small model 2026-03-08 19:27:15 -04:00
opencode
1db292f4df release: v1.2.22 2026-03-08 22:34:59 +00:00
Sebastian
49a3a9fe36 guard tui exit (#16640) 2026-03-08 23:14:41 +01:00
Luke Parker
e51ed460a6 fix(tui): canonicalize cwd after chdir (#16641) 2026-03-09 07:57:48 +10:00
David Hill
d15c2ce349 tui: fix sidebar background color when collapsed
When the sidebar was collapsed (not on mobile), the background color was showing as the stronger variant instead of matching the base background. This fixes the hover state detection so users see a consistent lighter background when the sidebar is in collapsed mode.
2026-03-08 13:34:56 +00:00
David Hill
5cc4bb4089 app: suppress hover when opening project menu or right-clicking to prevent flickering 2026-03-08 13:31:18 +00:00
Shoubhit Dash
6e9e027886 fix: trim retained desktop terminal buffers (#16583) 2026-03-08 07:50:04 -05:00
opencode-agent[bot]
f9a3d129a4 chore: update nix node_modules hashes 2026-03-08 12:25:35 +00:00
Adam
c53d1d3ad8 fix(app): less auto-expand/collapse 2026-03-08 07:11:15 -05:00
Adam
f386137fba chore: refactoring ui hooks 2026-03-08 07:11:15 -05:00
Adam
c797b60069 fix(app): messages not loading reliably 2026-03-08 07:11:15 -05:00
Shoubhit Dash
a139e9297d fix: prune and evict stale app session caches (#16584) 2026-03-08 07:10:00 -05:00
Shoubhit Dash
050f99ec54 test: make process cwd check cross-platform (#16594) 2026-03-08 06:56:45 -05:00
Roy Bruschini
23ed652901 docs(zen.mdx): correct Italian grammar and punctuation errors (#16590) 2026-03-08 16:40:06 +05:30
tobwen
13a68f3de3 fix(opencode): avoid TTY corruption from double cleanup (#16565)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:55:33 +05:30
Nate Williams
fdad35aaa7 fix(tui): fix broken /mcp toggling (#16431)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:31:09 +05:30
Dax
a2ce4eb650 test: remove unused Ripgrep.search coverage (#16554) 2026-03-07 21:40:57 -05:00
David Hill
8fa04986cf Revert "tui: dock auto-accept after thinking and move Add file to bottom-left"
This reverts commit 69cb49f7cc.
2026-03-08 01:31:09 +00:00
David Hill
a5710ed3e1 Revert "tui: keep model + thinking selectors beside Add file"
This reverts commit 426dcfa3b0.
2026-03-08 01:31:06 +00:00
David Hill
2efdc9df93 Revert "tui: add more editor bottom padding for prompt controls"
This reverts commit 981353793d.
2026-03-08 01:31:03 +00:00
David Hill
0c245886fe Revert "tui: expose auto-accept as a permissions select"
This reverts commit 12d862dbd3.
2026-03-08 01:31:00 +00:00
David Hill
f03288b411 Revert "tui: use text-base color for prompt selects"
This reverts commit 207ebf4b8c.
2026-03-08 01:30:55 +00:00
David Hill
09388c98f3 Revert "tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple"
This reverts commit ae25c1e7b7.
2026-03-08 01:27:45 +00:00
David Hill
ae25c1e7b7 tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple 2026-03-08 01:21:45 +00:00
David Hill
0813c14cc6 tui: restore new-session logo on dev so users recognize OpenCode immediately 2026-03-08 01:18:42 +00:00
David Hill
b5151c421f tui: revert new-session logo on dev so this UI change only ships with auto-accept-permissions 2026-03-08 01:10:52 +00:00
David Hill
e66fd079db tui: add opencode logo to new session screen so users can immediately identify the app when starting a fresh session 2026-03-08 00:59:03 +00:00
David Hill
207ebf4b8c tui: use text-base color for prompt selects
Select triggers in the composer now use the normal text color so model/thinking/permissions controls read consistently with the rest of the input UI.
2026-03-08 00:53:57 +00:00
David Hill
12d862dbd3 tui: expose auto-accept as a permissions select
Lets people explicitly choose between normal permission prompts and auto-accept while composing, without relying on an ambiguous icon state.
2026-03-08 00:53:57 +00:00
David Hill
981353793d tui: add more editor bottom padding for prompt controls
Gives typed text more breathing room above the Add file/model/thinking row so the controls don’t visually crowd what you’re writing.
2026-03-08 00:53:57 +00:00
David Hill
426dcfa3b0 tui: keep model + thinking selectors beside Add file
People change models and thinking settings while composing, so keeping those controls next to the Add file button avoids hunting in the footer and reduces context switching mid-message.
2026-03-08 00:53:57 +00:00
David Hill
69cb49f7cc tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:53:57 +00:00
Dax Raad
e30678a088 test: normalize ripgrep path assertion on windows 2026-03-07 19:47:57 -05:00
opencode-agent[bot]
771b29a857 chore: generate 2026-03-08 00:31:35 +00:00
Dax Raad
e6d1aae33a test: lock in process, ripgrep, and installation helpers 2026-03-07 19:30:32 -05:00
David Hill
9dc8ac4734 tui: revert prompt control docking
Restore the previous prompt control layout after the dock/position changes made the composer feel less familiar.

This brings auto-accept back to its prior spot and returns Add file to the previous placement.
2026-03-08 00:17:28 +00:00
David Hill
fdd037ba20 tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:08:37 +00:00
654 changed files with 29493 additions and 18032 deletions

View File

@@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Get baseline download URL
id: bun-url
shell: bash
@@ -31,6 +23,23 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies
run: bun install
shell: bash

View File

@@ -115,6 +115,9 @@ jobs:
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
@@ -149,6 +152,10 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
@@ -254,6 +261,10 @@ jobs:
- host: macos-latest
target: aarch64-apple-darwin
platform_flag: --mac --arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win

38
.github/workflows/storybook.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: storybook
on:
push:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
pull_request:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: storybook build
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Build Storybook
run: bun --cwd packages/storybook build

View File

@@ -6,6 +6,16 @@ on:
- dev
pull_request:
workflow_dispatch:
concurrency:
# Keep every run on dev so cancelled checks do not pollute the default branch
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
permissions:
contents: read
jobs:
unit:
name: unit (${{ matrix.settings.name }})
@@ -86,18 +96,3 @@ jobs:
path: |
packages/app/e2e/test-results
packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"

2
.gitignore vendored
View File

@@ -17,7 +17,7 @@ ts-dist
/result
refs
Session.vim
opencode.json
/opencode.json
a.out
target
.scripts

View File

@@ -1,3 +1,4 @@
plans/
bun.lock
package.json
package-lock.json

View File

@@ -5,6 +5,11 @@
"options": {},
},
},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
},
},
"mcp": {},
"tools": {
"github-triage": false,

View File

@@ -5,16 +5,8 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -50,7 +42,10 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -73,8 +68,7 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -4,5 +4,3 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

View File

@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

1180
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -103,6 +103,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go",
})
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
@@ -116,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
},
})

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
}
}

View File

@@ -43,6 +43,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.31",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -70,6 +70,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
@@ -167,6 +169,32 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
### Add hooks
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
## Writing New Tests
1. Choose appropriate folder or create new one

View File

@@ -3,11 +3,11 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionTimelineHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -16,6 +16,7 @@ import {
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
@@ -29,6 +30,69 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
return page.evaluate((id) => {
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
}, id)
}
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
await page.evaluate((id) => {
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
}, id)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
@@ -244,9 +308,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
const header = page.locator(sessionTimelineHeaderSelector).first()
await expect(header).toBeVisible({ timeout: 30_000 })
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -262,7 +324,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
@@ -542,12 +604,19 @@ export async function seedSessionTask(
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
return false
if (!("sessionId" in part.state.metadata)) return false
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
if (!("sessionId" in part.state.metadata)) return
const id = part.state.metadata.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })

View File

@@ -1,4 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -91,6 +92,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({

View File

@@ -25,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
})
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect(otherButton).toHaveCount(0)
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)

View File

@@ -1,4 +1,5 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -6,18 +7,29 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await prompt.fill("/terminal")
await expect(slash).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await waitTerminalReady(page, { term: terminal })
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})

View File

@@ -1,5 +1,6 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
@@ -51,8 +52,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionTimelineHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>

View File

@@ -1,12 +1,16 @@
import { test, expect } from "../fixtures"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
composerEvent,
type ComposerDriverState,
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
@@ -42,12 +46,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
for (let i = 0; i < 3; i++) {
const count = await dock.count()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
await expect(dock).toBeVisible()
await dock.getByRole("button", { name: label }).click()
}
async function setAutoAccept(page: any, enabled: boolean) {
@@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function expectQuestionBlocked(page: any) {
await expect(page.locator(questionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectQuestionOpen(page: any) {
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function expectPermissionBlocked(page: any) {
await expect(page.locator(permissionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectPermissionOpen(page: any) {
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
composer: {
enabled: true,
sessions: {},
},
}
})
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
if (!prev.probe) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { probe: prev.probe }
}
} else {
composer.sessions[input.sessionID] = {
...prev,
driver: input.driver,
}
}
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}
const read = () =>
page.evaluate((sessionID) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
const api = {
async clear() {
await write(undefined)
return api
},
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: true, todos })
return api
},
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: false, todos })
return api
},
async expectOpen(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: false,
hidden: false,
count: states.length,
states,
})
return api
},
async expectCollapsed(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: true,
hidden: true,
count: states.length,
states,
})
return api
},
async expectClosed() {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
return api
},
async collapse() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
async expand() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
}
return api
}
async function withMockPermission<T>(
page: any,
request: {
@@ -70,7 +184,7 @@ async function withMockPermission<T>(
always?: string[]
},
opts: { child?: any } | undefined,
fn: () => Promise<T>,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) {
let pending = [
{
@@ -119,8 +233,14 @@ async function withMockPermission<T>(
if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try {
return await fn()
return await fn(state)
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
@@ -173,14 +293,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectQuestionOpen(page)
})
})
})
@@ -199,15 +317,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
async (state) => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectPermissionOpen(page)
},
)
})
@@ -226,15 +343,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async () => {
async (state) => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectPermissionOpen(page)
},
)
})
@@ -254,15 +370,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
async (state) => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectPermissionOpen(page)
},
)
})
@@ -301,14 +416,12 @@ test("child session question request blocks parent dock and unblocks after submi
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
@@ -344,17 +457,15 @@ test("child session permission request blocks parent dock and supports allow onc
metadata: { description: "Need child permission" },
},
{ child },
async () => {
async (state) => {
await page.goto(page.url())
const dock = page.locator(permissionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
await expectPermissionOpen(page)
},
)
} finally {
@@ -365,36 +476,31 @@ test("child session permission request blocks parent dock and supports allow onc
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
const dock = await todoDock(page, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
})
})
@@ -414,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")

View File

@@ -0,0 +1,217 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, file)
}
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})

View File

@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
originalTitle,
)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
@@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
)
.toBe(renamedTitle)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
renamedTitle,
)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, withSession } from "../actions"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).toBeVisible()
await waitTerminalReady(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()

View File

@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
const currentThemeId = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
expect(firstTheme).toBeTruthy()
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
.map((x) => x.trim())
.find((x) => x && x !== currentTheme)
expect(nextTheme).toBeTruthy()
await items.nth(1).click()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe("oc-1")
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
expect(dataTheme).toBe(storedThemeId)
})
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
})
await gotoSession()
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
})
.toBe("oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-light")
})
})
.toBeNull()
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-dark")
})
})
.toBeNull()
})
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,4 +1,5 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey)
}
await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() })
})

View File

@@ -0,0 +1,46 @@
import type { Page } from "@playwright/test"
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
async function open(page: Page) {
const term = page.locator(terminalSelector).first()
const visible = await term.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term })
return term
}
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}`
await gotoSession()
const term = await open(page)
const id = await term.getAttribute("data-pty-id")
if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term })
await runTerminal(page, {
term,
cmd: `export ${name}=${token}; echo ${token}`,
token,
})
await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, {
term,
cmd: `echo $${name}`,
token,
timeout: 15_000,
})
})
})

View File

@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
@@ -17,16 +18,7 @@ async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
}
async function store(page: Page, key: string) {
@@ -44,27 +36,27 @@ async function store(page: Page, key: string) {
}, key)
}
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession()
await open(page)
await run(page, `echo ${one}`)
await runTerminal(page, { cmd: `echo ${one}`, token: one })
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await run(page, `echo ${two}`)
await runTerminal(page, { cmd: `echo ${two}`, token: two })
await tabs
.filter({ hasText: /Terminal 1/ })
.first()
.click()
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
@@ -72,11 +64,31 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return first.includes(one) && second.includes(two)
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
{ timeout: 5_000 },
)
.toBe(true)
.toEqual({ first: false, second: true })
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 5_000 },
)
.toEqual({ first: true, second: false })
})
})

View File

@@ -1,4 +1,5 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
}
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
await waitTerminalReady(page, { term: terminal })
})

View File

@@ -2,7 +2,8 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts"]
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
}

View File

@@ -57,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
}
export function workspacePersistKey(directory: string, key: string) {
const head = directory.slice(0, 12) || "workspace"
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.21",
"version": "1.2.25",
"description": "",
"type": "module",
"exports": {
@@ -45,8 +45,8 @@
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
@@ -56,6 +56,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -17,6 +18,7 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,

View File

@@ -1,6 +1,13 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var key = "opencode-theme-id"
var themeId = localStorage.getItem(key) || "oc-2"
if (themeId === "oc-1") {
themeId = "oc-2"
localStorage.setItem(key, themeId)
localStorage.removeItem("opencode-theme-css-light")
localStorage.removeItem("opencode-theme-css-dark")
}
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
@@ -9,9 +16,9 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
if (themeId === "oc-2") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
var css = localStorage.getItem("opencode-theme-css-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"

View File

@@ -73,6 +73,7 @@ const serverEnv = {
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>
const runnerEnv = {

View File

@@ -1,14 +1,30 @@
import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type Duration, Effect } from "effect"
import {
type Component,
createMemo,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -22,13 +38,13 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { Dynamic } from "solid-js/web"
import { useCheckServerHealth } from "./utils/server-health"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -52,7 +68,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
}
declare global {
@@ -62,6 +78,9 @@ declare global {
deepLinks?: string[]
wsl?: boolean
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
}
}
@@ -115,7 +134,11 @@ export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
@@ -132,24 +155,126 @@ export function AppBaseProviders(props: ParentProps) {
)
}
function ServerKey(props: ParentProps) {
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
return (
<Show when={server.key} keyed>
{props.children}
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
@@ -164,7 +289,7 @@ export function AppInterface(props: {
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider>
)
}

View File

@@ -0,0 +1,447 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
type Mem = Performance & {
memory?: {
usedJSHeapSize: number
jsHeapSizeLimit: number
}
}
type Evt = PerformanceEntry & {
interactionId?: number
processingStart?: number
}
type Shift = PerformanceEntry & {
hadRecentInput: boolean
value: number
}
type Obs = PerformanceObserverInit & {
durationThreshold?: number
}
const span = 5000
const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return
return `${n.toFixed(d)}ms`
}
const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
return `${Math.round(n)}`
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
}
const session = (path: string) => path.includes("/session")
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
return (
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip>
)
}
export function DebugBar() {
const language = useLanguage()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
cls: undefined as number | undefined,
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
},
inp: undefined as number | undefined,
jank: undefined as number | undefined,
long: {
block: undefined as number | undefined,
count: undefined as number | undefined,
max: undefined as number | undefined,
},
nav: {
dur: undefined as number | undefined,
pending: false,
},
})
const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
const value = heap()
if (value === undefined) return na()
return `${Math.round(value * 100)}%`
}
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
let prev = ""
let start = 0
let init = false
let one = 0
let two = 0
createEffect(() => {
const busy = routing()
const next = `${location.pathname}${location.search}`
if (!init) {
init = true
prev = next
return
}
if (busy) {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = 0
two = 0
if (start !== 0) return
start = performance.now()
if (session(prev)) setState("nav", { dur: undefined, pending: true })
return
}
if (start === 0) {
prev = next
return
}
const at = start
const from = prev
start = 0
prev = next
if (!(session(from) || session(next))) return
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = requestAnimationFrame(() => {
one = 0
two = requestAnimationFrame(() => {
two = 0
setState("nav", { dur: performance.now() - at, pending: false })
})
})
})
onMount(() => {
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
let hasLong = false
let poll: number | undefined
let raf = 0
let last = 0
let snap = 0
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
while (list[0] && at - list[0].at > span) list.shift()
}
const syncFrame = (at: number) => {
trim(fps, span, at)
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
const jank = fps.filter((entry) => entry.dur > 32).length
batch(() => {
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
setState("gap", gap > 0 ? gap : undefined)
setState("jank", jank)
})
}
const syncLong = (at = performance.now()) => {
if (!hasLong) return
trim(long, span, at)
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
setState("long", { block, count: long.length, max })
}
const syncInp = (at = performance.now()) => {
for (const [key, entry] of seen) {
if (at - entry.at > span) seen.delete(key)
}
let delay = 0
let inp = 0
for (const entry of seen.values()) {
delay = Math.max(delay, entry.delay)
inp = Math.max(inp, entry.dur)
}
batch(() => {
setState("delay", delay > 0 ? delay : undefined)
setState("inp", inp > 0 ? inp : undefined)
})
}
const syncHeap = () => {
const mem = (performance as Mem).memory
if (!mem) return
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
}
const reset = () => {
fps.length = 0
long.length = 0
seen.clear()
last = 0
snap = 0
batch(() => {
setState("fps", undefined)
setState("gap", undefined)
setState("jank", undefined)
setState("delay", undefined)
setState("inp", undefined)
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
})
}
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
if (typeof PerformanceObserver === "undefined") return false
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
try {
ob.observe(init)
obs.push(ob)
return true
} catch {
ob.disconnect()
return false
}
}
if (
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
const add = entries.reduce((sum, entry) => {
const item = entry as Shift
if (item.hadRecentInput) return sum
return sum + item.value
}, 0)
if (add === 0) return
setState("cls", (value) => (value ?? 0) + add)
})
) {
setState("cls", 0)
}
if (
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
const at = performance.now()
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
syncLong(at)
})
) {
hasLong = true
setState("long", { block: 0, count: 0, max: 0 })
}
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
for (const raw of entries) {
const entry = raw as Evt
if (entry.duration < 16) continue
const key =
entry.interactionId && entry.interactionId > 0
? entry.interactionId
: `${entry.name}:${Math.round(entry.startTime)}`
const prev = seen.get(key)
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
seen.set(key, {
at: entry.startTime,
delay: Math.max(prev?.delay ?? 0, delay),
dur: Math.max(prev?.dur ?? 0, entry.duration),
})
if (seen.size <= 200) continue
const first = seen.keys().next().value
if (first !== undefined) seen.delete(first)
}
syncInp()
})
const loop = (at: number) => {
if (document.visibilityState !== "visible") {
raf = 0
return
}
if (last === 0) {
last = at
raf = requestAnimationFrame(loop)
return
}
fps.push({ at, dur: at - last })
last = at
if (at - snap >= 250) {
snap = at
syncFrame(at)
}
raf = requestAnimationFrame(loop)
}
const stop = () => {
if (raf !== 0) cancelAnimationFrame(raf)
raf = 0
if (poll === undefined) return
clearInterval(poll)
poll = undefined
}
const start = () => {
if (document.visibilityState !== "visible") return
if (poll === undefined) {
poll = window.setInterval(() => {
syncLong()
syncInp()
syncHeap()
}, 1000)
}
if (raf !== 0) return
raf = requestAnimationFrame(loop)
}
const vis = () => {
if (document.visibilityState !== "visible") {
stop()
return
}
reset()
start()
}
syncHeap()
start()
document.addEventListener("visibilitychange", vis)
onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect()
})
})
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
wide
/>
</div>
</aside>
)
}

View File

@@ -0,0 +1,159 @@
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
export type ModelErr = {
id?: string
name?: string
}
export type HeaderErr = {
key?: string
value?: string
}
export type ModelRow = {
row: string
id: string
name: string
err: ModelErr
}
export type HeaderRow = {
row: string
key: string
value: string
err: HeaderErr
}
export type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
err: {
providerID?: string
name?: string
baseURL?: string
}
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
export function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const models = input.form.models.map((m) => {
const id = m.id.trim()
const idError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: idError, name: nameError }
})
const modelsValid = models.every((m) => !m.id && !m.name)
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headers = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headers.every((h) => !h.key && !h.value)
const headerConfig = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const err = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { err, models, headers }
return {
err,
models,
headers,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
},
models: modelConfig,
},
},
}
}
let row = 0
const nextRow = () => `row-${row++}`
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })

View File

@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import { validateCustomProvider } from "./dialog-custom-provider-form"
const t = (key: string) => key
describe("validateCustomProvider", () => {
test("builds trimmed config payload", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: " Custom Provider ",
baseURL: "https://api.example.com ",
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
headers: [
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
],
saving: false,
err: {},
},
t,
disabledProviders: [],
existingProviderIDs: new Set(),
})
expect(result.result).toEqual({
providerID: "custom-provider",
name: "Custom Provider",
key: undefined,
config: {
npm: "@ai-sdk/openai-compatible",
name: "Custom Provider",
env: ["CUSTOM_PROVIDER_KEY"],
options: {
baseURL: "https://api.example.com",
headers: {
"X-Test": "enabled",
},
},
models: {
"model-a": { name: "Model A" },
},
},
})
})
test("flags duplicate rows and allows reconnecting disabled providers", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: "Provider",
baseURL: "https://api.example.com",
apiKey: "secret",
models: [
{ row: "m0", id: "model-a", name: "Model A", err: {} },
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
],
headers: [
{ row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} },
],
saving: false,
err: {},
},
t,
disabledProviders: ["custom-provider"],
existingProviderIDs: new Set(["custom-provider"]),
})
expect(result.result).toBeUndefined()
expect(result.err.providerID).toBeUndefined()
expect(result.models[1]).toEqual({
id: "provider.custom.error.duplicate",
name: undefined,
})
expect(result.headers[1]).toEqual({
key: "provider.custom.error.duplicate",
value: undefined,
})
})
})

View File

@@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { For } from "solid-js"
import { createStore } from "solid-js/store"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = {
back?: "providers" | "close"
}
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
name: "",
baseURL: "",
apiKey: "",
models: [{ id: "", name: "" }],
headers: [{ key: "", value: "" }],
models: [modelRow()],
headers: [headerRow()],
saving: false,
})
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
err: {},
})
const goBack = () => {
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
}
const addModel = () => {
setForm("models", (v) => [...v, { id: "", name: "" }])
setErrors("models", (v) => [...v, {}])
setForm(
"models",
produce((rows) => {
rows.push(modelRow())
}),
)
}
const removeModel = (index: number) => {
if (form.models.length <= 1) return
setForm("models", (v) => v.filter((_, i) => i !== index))
setErrors("models", (v) => v.filter((_, i) => i !== index))
setForm(
"models",
produce((rows) => {
rows.splice(index, 1)
}),
)
}
const addHeader = () => {
setForm("headers", (v) => [...v, { key: "", value: "" }])
setErrors("headers", (v) => [...v, {}])
setForm(
"headers",
produce((rows) => {
rows.push(headerRow())
}),
)
}
const removeHeader = (index: number) => {
if (form.headers.length <= 1) return
setForm("headers", (v) => v.filter((_, i) => i !== index))
setErrors("headers", (v) => v.filter((_, i) => i !== index))
setForm(
"headers",
produce((rows) => {
rows.splice(index, 1)
}),
)
}
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
setForm(key, value)
if (key === "apiKey") return
setForm("err", key, undefined)
}
const setModel = (index: number, key: "id" | "name", value: string) => {
batch(() => {
setForm("models", index, key, value)
setForm("models", index, "err", key, undefined)
})
}
const setHeader = (index: number, key: "key" | "value", value: string) => {
batch(() => {
setForm("headers", index, key, value)
setForm("headers", index, "err", key, undefined)
})
}
const validate = () => {
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
})
setErrors(output.errors)
batch(() => {
setForm("err", output.err)
output.models.forEach((err, index) => setForm("models", index, "err", err))
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
})
return output.result
}
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={(v) => setForm("providerID", v)}
validationState={errors.providerID ? "invalid" : undefined}
error={errors.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) => setForm("name", v)}
validationState={errors.name ? "invalid" : undefined}
error={errors.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) => setForm("baseURL", v)}
validationState={errors.baseURL ? "invalid" : undefined}
error={errors.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) => setForm("apiKey", v)}
onChange={(v) => setField("apiKey", v)}
/>
</div>
@@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) {
<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">
<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) => setForm("models", i(), "id", v)}
validationState={errors.models[i()]?.id ? "invalid" : undefined}
error={errors.models[i()]?.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
/>
</div>
<div class="flex-1">
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setForm("models", i(), "name", v)}
validationState={errors.models[i()]?.name ? "invalid" : undefined}
error={errors.models[i()]?.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
</div>
<IconButton
@@ -382,16 +272,16 @@ export function DialogCustomProvider(props: Props) {
<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">
<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) => setForm("headers", i(), "key", v)}
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
error={errors.headers[i()]?.key}
onChange={(v) => setHeader(i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined}
error={h.err.key}
/>
</div>
<div class="flex-1">
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setForm("headers", i(), "value", v)}
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
error={errors.headers[i()]?.value}
onChange={(v) => setHeader(i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined}
error={h.err.value}
/>
</div>
<IconButton

View File

@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useNavigate, useParams } from "@solidjs/router"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
@@ -132,9 +134,14 @@ function createFileEntries(props: {
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 = props.tabs().all()
const active = props.tabs().active()
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")
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const params = useParams()
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
@@ -422,7 +426,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
</Show>
</div>
</Match>

View File

@@ -14,7 +14,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface ServerFormProps {
value: string
@@ -41,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
@@ -56,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultUrl, canDefault, setDefault }
return { defaultKey, canDefault, setDefault }
}
function useServerPreview(fetcher: typeof fetch) {
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
@@ -92,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
@@ -115,7 +121,7 @@ function ServerForm(props: ServerFormProps) {
return (
<div class="px-5">
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
type="text"
@@ -143,7 +149,7 @@ function ServerForm(props: ServerFormProps) {
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder="username"
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
@@ -152,7 +158,7 @@ function ServerForm(props: ServerFormProps) {
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder="password"
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
@@ -170,15 +176,15 @@ export function DialogSelectServer() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
name: "",
username: "",
username: DEFAULT_USERNAME,
password: "",
adding: false,
error: "",
@@ -201,7 +207,7 @@ export function DialogSelectServer() {
setStore("addServer", {
url: "",
name: "",
username: "",
username: DEFAULT_USERNAME,
password: "",
adding: false,
error: "",
@@ -264,7 +270,7 @@ export function DialogSelectServer() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
setStore("status", reconcile(results))
@@ -362,9 +368,9 @@ export function DialogSelectServer() {
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
const result = await checkServerHealth(conn.http, fetcher)
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -404,7 +410,7 @@ export function DialogSelectServer() {
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http, fetcher)
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -441,7 +447,7 @@ export function DialogSelectServer() {
showForm: true,
url: "",
name: "",
username: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
@@ -494,8 +500,8 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
if ((await platform.getDefaultServer?.()) === url) {
platform.setDefaultServer?.(null)
}
}
@@ -536,7 +542,7 @@ export function DialogSelectServer() {
if (x) select(x)
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
>
{(i) => {
const key = ServerConnection.key(i)
@@ -551,7 +557,7 @@ export function DialogSelectServer() {
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -584,14 +590,14 @@ export function DialogSelectServer() {
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}

View File

@@ -17,7 +17,6 @@ import {
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -27,7 +26,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
@@ -37,8 +35,11 @@ import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
@@ -48,7 +49,7 @@ import {
type PromptHistoryStoredEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
@@ -61,6 +62,11 @@ interface PromptInputProps {
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -102,20 +108,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prompt = usePrompt()
const layout = useLayout()
const comments = useComments()
const params = useParams()
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const inset = 44
const inset = 52
const space = `${inset}px`
const scrollCursorIntoView = () => {
const container = scrollRef
@@ -150,13 +157,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const queueScroll = () => {
requestAnimationFrame(scrollCursorIntoView)
const queueScroll = (count = 2) => {
requestAnimationFrame(() => {
scrollCursorIntoView()
if (count > 1) queueScroll(count - 1)
})
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const commentInReview = (path: string) => {
const sessionID = params.id
@@ -209,7 +221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const active = activeFileTab()
const order = active ? [active, ...all.filter((x) => x !== active)] : all
const seen = new Set<string>()
const paths: string[] = []
@@ -255,6 +267,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
transform: `scale(${0.95 + value * 0.05})`,
filter: `blur(${(1 - value) * 2}px)`,
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
})
const buttons = createMemo(() => motion(buttonsSpring()))
const shell = createMemo(() => motion(1 - buttonsSpring()))
const control = createMemo(() => ({ height: "28px", ...buttons() }))
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
@@ -490,6 +511,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setComposing(false)
}
const handleCompositionStart = () => {
setComposing(true)
}
const handleCompositionEnd = () => {
setComposing(false)
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
})
}
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -680,24 +713,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const reconcile = (input: Prompt) => {
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(input)
return
}
const dom = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
renderEditorWithCursor(input)
}
createEffect(
on(
() => prompt.current(),
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image")
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
renderEditorWithCursor(inputParts)
(parts) => {
if (composing()) return
reconcile(parts.filter((part) => part.type !== "image"))
},
),
)
@@ -921,6 +957,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setCurrentHistory("entries", next)
}
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
for (const item of edit.context) {
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
setStore("mode", "normal")
setStore("popover", null)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
prompt.set(edit.prompt, promptLength(edit.prompt))
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, promptLength(edit.prompt))
queueScroll()
})
props.onEditLoaded?.()
},
{ defer: true },
),
)
const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({
direction,
@@ -937,7 +1012,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active,
@@ -975,6 +1050,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
@@ -1174,7 +1252,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeImageAttachment}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
@@ -1192,7 +1270,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef?.focus()
}}
>
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
>
<div
data-component="prompt-input"
ref={(el) => {
@@ -1208,28 +1290,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
spellcheck={store.mode === "normal"}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
style={{ "padding-bottom": space }}
/>
<Show when={!prompt.dirty()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space }}
>
{placeholder()}
</div>
</Show>
</div>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
@@ -1238,7 +1332,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) addImageAttachment(file)
if (file) void addAttachment(file)
e.currentTarget.value = ""
}}
/>
@@ -1260,11 +1354,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1302,11 +1392,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1364,10 +1450,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{
padding: "0 4px 0 8px",
opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
...shell(),
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
@@ -1387,13 +1470,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
triggerStyle={control()}
variant="ghost"
/>
</TooltipKeybind>
@@ -1411,13 +1488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1446,13 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: {
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1484,48 +1549,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
triggerStyle={control()}
variant="ghost"
/>
</TooltipKeybind>
</div>
</div>
<div class="shrink-0">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind
placement="top"
gutter={4}
openDelay={2000}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
>
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>
</TooltipKeybind>
)}
onSelect={(mode) => mode && setMode(mode)}
fill
pad="none"
class="w-[68px]"
/>
</div>
</div>
</DockTray>
</Show>

View File

@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
expect(await attachmentMime(file)).toBe("application/pdf")
})
test("normalizes structured text types to text/plain", async () => {
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("accepts text files even with a misleading browser mime", async () => {
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("rejects binary files", async () => {
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
expect(await attachmentMime(file)).toBeUndefined()
})
})

View File

@@ -4,12 +4,27 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
import { attachmentMime } from "./files"
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
@@ -35,28 +50,41 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
const reader = new FileReader()
reader.onload = () => {
const editor = input.editor()
if (!editor) return
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime: file.type,
dataUrl,
}
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
const warn = () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
}
const removeImageAttachment = (id: string) => {
const add = async (file: File, toast = true) => {
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
return false
}
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime,
dataUrl: url,
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true
}
const addAttachment = (file: File) => add(file)
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
@@ -72,21 +100,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
}
return
}
if (fileItems.length > 0) {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
let found = false
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
return
}
@@ -96,7 +119,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file) {
await addImageAttachment(file)
await addAttachment(file)
return
}
}
@@ -153,11 +176,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
let found = false
for (const file of Array.from(dropped)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file)
}
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
}
onMount(() => {
@@ -173,8 +197,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
})
return {
addImageAttachment,
removeImageAttachment,
addAttachment,
removeAttachment,
handlePaste,
}
}

View File

@@ -0,0 +1,119 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096
function kind(type: string) {
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
}
function ext(name: string) {
const idx = name.lastIndexOf(".")
if (idx === -1) return ""
return name.slice(idx + 1).toLowerCase()
}
function textMime(type: string) {
if (!type) return false
if (type.startsWith("text/")) return true
if (TEXT_MIMES.has(type)) return true
if (type.endsWith("+json")) return true
return type.endsWith("+xml")
}
function textBytes(bytes: Uint8Array) {
if (bytes.length === 0) return true
let count = 0
for (const byte of bytes) {
if (byte === 0) return false
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
}
return count / bytes.length <= 0.3
}
export async function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type
const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (textMime(type)) return "text/plain"
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
if (!textBytes(bytes)) return
return "text/plain"
}

View File

@@ -7,12 +7,16 @@ const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
sessionID?: string
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: string[] = []
const syncedDirectories: string[] = []
@@ -28,7 +32,12 @@ const clientFor = (directory: string) => {
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
return {
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
},
shell: async () => {
sentShell.push(directory)
@@ -129,9 +138,16 @@ beforeAll(async () => {
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => {
optimistic.push(value)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
},
remove: () => undefined,
},
@@ -144,7 +160,21 @@ beforeAll(async () => {
useGlobalSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
return [{}, () => undefined]
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}),
}))
@@ -170,11 +200,13 @@ beforeEach(() => {
createdSessions.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
describe("prompt submit worktree selection", () => {
@@ -207,7 +239,7 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
})
test("applies auto-accept to newly created sessions", async () => {
@@ -271,4 +303,32 @@ describe("prompt submit worktree selection", () => {
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
})

View File

@@ -1,6 +1,7 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { Binary } from "@opencode-ai/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
@@ -9,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { Identifier } from "@/utils/id"
@@ -25,6 +26,145 @@ type PendingPrompt = {
const pending = new Map<string, PendingPrompt>()
export type FollowupDraft = {
sessionID: string
sessionDirectory: string
prompt: Prompt
context: (ContextItem & { key: string })[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
globalSync: ReturnType<typeof useGlobalSync>
sync: ReturnType<typeof useSync>
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const setBusy = () => {
if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "busy" })
}
const setIdle = () => {
if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "idle" })
}
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy()
try {
if (!(await wait())) {
setIdle()
return false
}
await input.client.session.command({
sessionID: input.draft.sessionID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
return true
} catch (err) {
setIdle()
throw err
}
}
const messageID = input.messageID ?? Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images,
text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
})
const message: Message = {
id: messageID,
sessionID: input.draft.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
}
const add = () =>
input.sync.session.optimistic.add({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
})
const remove = () =>
input.sync.session.optimistic.remove({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
setBusy()
add()
try {
if (!(await wait())) {
setIdle()
remove()
return false
}
await input.client.session.promptAsync({
sessionID: input.draft.sessionID,
agent: input.draft.agent,
model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant,
})
return true
} catch (err) {
setIdle()
remove()
throw err
}
}
type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
@@ -41,6 +181,9 @@ type PromptSubmitInput = {
setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void
shouldQueue?: Accessor<boolean>
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -82,6 +225,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
input.onAbort?.()
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
@@ -116,6 +261,26 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
}
const seed = (dir: string, info: Session) => {
const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
@@ -191,7 +356,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
session = await client.session
const created = await client.session
.create()
.then((x) => x.data ?? undefined)
.catch((err) => {
@@ -201,7 +366,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return undefined
})
if (session) {
if (created) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
@@ -215,14 +382,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
input.onSubmit?.()
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
const context = prompt.context.items().slice()
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
prompt.reset()
@@ -243,6 +418,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext()
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
client.session
@@ -295,48 +479,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const context = prompt.context.items().slice()
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: currentPrompt,
context,
images,
text,
sessionID: session.id,
messageID,
sessionDirectory,
})
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
model,
variant,
}
const addOptimisticMessage = () =>
sync.session.optimistic.add({
directory: sessionDirectory,
sessionID: session.id,
message: optimisticMessage,
parts: optimisticParts,
})
const removeOptimisticMessage = () =>
const removeOptimisticMessage = () => {
sync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -393,20 +548,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return true
}
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.promptAsync({
sessionID: session.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
}
void send().catch((err) => {
void sendFollowupDraft({
client,
sync,
globalSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })

View File

@@ -10,6 +10,7 @@ import {
type ParentProps,
Show,
} from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
@@ -25,6 +26,7 @@ interface ServerRowProps extends ParentProps {
}
export function ServerRow(props: ServerRowProps) {
const language = useLanguage()
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
@@ -65,22 +67,26 @@ export function ServerRow(props: ServerRowProps) {
return (
<Tooltip
class="flex-1"
class="flex-1 min-w-0"
value={tooltipValue()}
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
placement="top-start"
inactive={!truncated() && !props.conn.displayName}
>
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div class="flex flex-col items-start">
<div class="flex flex-row items-center gap-2">
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
<div class="flex flex-col items-start min-w-0 w-full">
<div class="flex flex-row items-center gap-2 min-w-0 w-full">
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
{name()}
</span>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version}
</span>
</Show>
@@ -96,7 +102,7 @@ export function ServerRow(props: ServerRowProps) {
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
) : (
<span class="text-text-weaker">no username</span>
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)}
</span>
{conn().http.password && <span class="text-text-weak"></span>}

View File

@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -27,14 +29,17 @@ function openSessionContext(args: {
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo(
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (tabs().active() === "context") {
if (tabState.activeTab() === "context") {
tabs().close("context")
return
}

View File

@@ -1,8 +1,6 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams()
const sync = useSync()
const layout = useLayout()
const language = useLanguage()
const { params, view } = useSessionLayout()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(

View File

@@ -4,23 +4,21 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Popover } from "@opencode-ai/ui/popover"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
@@ -48,74 +46,68 @@ type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{
id: "vscode",
label: "VS Code",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "Antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "Android Studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "Sublime Text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "PowerShell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "Sublime Text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "Sublime Text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
type OpenIcon = OpenApp | "file-explorer"
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
@@ -134,101 +126,14 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
share?: {
url?: string
}
}
| undefined
sessionID: () => string | undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
const shareSession = () => {
const sessionID = args.sessionID()
if (!sessionID || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
const unshareSession = () => {
const sessionID = args.sessionID()
if (!sessionID || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
const copyLink = (onError: (error: unknown) => void) => {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch(onError)
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
args.platform.openLink(url)
}
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
}
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
const server = useServer()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -242,12 +147,6 @@ export function SessionHeader() {
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !!params.id)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
@@ -261,9 +160,9 @@ export function SessionHeader() {
})
const fileManager = createMemo(() => {
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
return { label: "File Manager", icon: "finder" as const }
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
})
createEffect(() => {
@@ -279,10 +178,7 @@ export function SessionHeader() {
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => {
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
return [app.id, ok] as const
}),
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
@@ -291,11 +187,23 @@ export function SessionHeader() {
const options = createMemo(() => {
return [
{ id: "finder", label: fileManager().label, icon: fileManager().icon },
...apps().filter((app) => exists[app.id]),
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
@@ -348,14 +256,6 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
const share = useSessionShare({
globalSDK,
currentSession,
sessionID: () => params.id,
projectDirectory,
platform,
})
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
@@ -383,7 +283,9 @@ export function SessionHeader() {
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
)}
</Show>
</Button>
@@ -394,7 +296,6 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-2">
<StatusPopover />
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
@@ -419,7 +320,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
@@ -427,17 +328,13 @@ export function SessionHeader() {
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Show
when={opening()}
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5 text-icon-base" />
</Show>
</div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button>
<div class="self-stretch w-px bg-border-weak-base" />
<DropdownMenu
gutter={4}
placement="bottom-end"
@@ -449,17 +346,20 @@ export function SessionHeader() {
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
@@ -476,8 +376,8 @@ export function SessionHeader() {
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
@@ -510,146 +410,27 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<Show when={showShare()}>
<div class="flex items-center">
<Popover
title={language.t("session.share.popover.title")}
description={
share.shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
gutter={4}
placement="bottom-end"
shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
triggerAs={Button}
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
classList: {
"rounded-r-none": share.shareUrl() !== undefined,
"border-r-0": share.shareUrl() !== undefined,
},
style: { scale: 1 },
}}
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
>
<div class="flex flex-col gap-2">
<Show
when={share.shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={share.shareSession}
disabled={share.state.share}
>
{share.state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</div>
}
>
<div class="flex flex-col gap-2">
<TextField
value={share.shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={share.unshareSession}
disabled={share.state.unshare}
>
{share.state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={share.viewShare}
disabled={share.state.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
placement="top"
gutter={8}
>
<IconButton
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
/>
</Tooltip>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
@@ -662,23 +443,7 @@ export function SessionHeader() {
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>

View File

@@ -4,6 +4,7 @@ import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
@@ -12,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
interface NewSessionViewProps {
worktree: string
onWorktreeChange: (value: string) => void
}
export function NewSessionView(props: NewSessionViewProps) {
@@ -52,7 +52,10 @@ export function NewSessionView(props: NewSessionViewProps) {
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
<div class="flex flex-col items-center gap-6">
<Mark class="w-10" />
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
</div>
<div class="w-full flex flex-col gap-4 items-center">
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">

View File

@@ -6,8 +6,10 @@ 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 { 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 SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
const terminal = useTerminal()
@@ -26,11 +28,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false
const match = props.terminal.title.match(/^Terminal (\d+)$/)
if (!match) return false
const parsed = Number(match[1])
if (!Number.isFinite(parsed) || parsed <= 0) return false
return parsed === number
return isDefaultTerminalTitle(props.terminal.title, number)
}
const label = () => {
@@ -53,21 +51,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const focus = () => {
if (store.editing) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
}
const edit = (e?: Event) => {

View File

@@ -1,16 +0,0 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
</div>
</div>
)
}

View File

@@ -1,16 +0,0 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
</div>
</div>
)
}

View File

@@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
@@ -113,6 +114,11 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
@@ -170,11 +176,9 @@ export const SettingsGeneral: Component = () => {
triggerVariant: "settings" as const,
})
const AppearanceSection = () => (
const GeneralSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
@@ -193,8 +197,70 @@ export const SettingsGeneral: Component = () => {
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<Select
data-action="settings-color-scheme"
@@ -211,6 +277,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "220px" }}
/>
</SettingsRow>
@@ -267,51 +334,7 @@ export const SettingsGeneral: Component = () => {
)}
</Select>
</SettingsRow>
</div>
</div>
)
const FeedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</div>
</SettingsList>
</div>
)
@@ -319,7 +342,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
@@ -355,7 +378,7 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
</div>
</SettingsList>
</div>
)
@@ -363,7 +386,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
@@ -408,7 +431,7 @@ export const SettingsGeneral: Component = () => {
)}
/>
</SettingsRow>
</div>
</SettingsList>
</div>
)
@@ -416,7 +439,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
@@ -452,7 +475,7 @@ export const SettingsGeneral: Component = () => {
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</SettingsList>
</div>
)
@@ -465,9 +488,9 @@ export const SettingsGeneral: Component = () => {
</div>
<div class="flex flex-col gap-8 w-full">
<AppearanceSection />
<GeneralSection />
<FeedSection />
<AppearanceSection />
<NotificationsSection />
@@ -482,7 +505,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
@@ -495,7 +518,7 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
</div>
</SettingsList>
</div>
)
}}
@@ -515,7 +538,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<SettingsRow
title={
<div class="flex items-center gap-2">
@@ -533,7 +556,7 @@ export const SettingsGeneral: Component = () => {
<Switch checked={value() === "wayland"} onChange={onChange} />
</div>
</SettingsRow>
</div>
</SettingsList>
</div>
)
}}
@@ -551,12 +574,12 @@ interface SettingsRowProps {
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
</div>
)
}

View File

@@ -9,6 +9,7 @@ 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"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -238,7 +239,7 @@ function useKeyCapture(input: {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
keybind: formatKeybind(next, input.language.t),
titles: [...conflicts.values()].join(", "),
}),
})
@@ -406,7 +407,7 @@ export const SettingsKeybinds: Component = () => {
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
@@ -432,7 +433,7 @@ export const SettingsKeybinds: Component = () => {
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Show>
)}

View File

@@ -0,0 +1,5 @@
import { type Component, type JSX } from "solid-js"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
}

View File

@@ -1,16 +0,0 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
</div>
</div>
)
}

View File

@@ -8,6 +8,7 @@ import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -100,7 +101,7 @@ export const SettingsModels: Component = () => {
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
@@ -124,7 +125,7 @@ export const SettingsModels: Component = () => {
)
}}
</For>
</div>
</SettingsList>
</div>
)}
</For>

View File

@@ -1,230 +0,0 @@
import { Select } from "@opencode-ai/ui/select"
import { showToast } from "@opencode-ai/ui/toast"
import { Component, For, createMemo, type JSX } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
type PermissionAction = "allow" | "ask" | "deny"
type PermissionObject = Record<string, PermissionAction>
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
type PermissionMap = Record<string, PermissionValue>
type PermissionItem = {
id: string
title: string
description: string
}
const ACTIONS = [
{ value: "allow", label: "settings.permissions.action.allow" },
{ value: "ask", label: "settings.permissions.action.ask" },
{ value: "deny", label: "settings.permissions.action.deny" },
] as const
const ITEMS = [
{
id: "read",
title: "settings.permissions.tool.read.title",
description: "settings.permissions.tool.read.description",
},
{
id: "edit",
title: "settings.permissions.tool.edit.title",
description: "settings.permissions.tool.edit.description",
},
{
id: "glob",
title: "settings.permissions.tool.glob.title",
description: "settings.permissions.tool.glob.description",
},
{
id: "grep",
title: "settings.permissions.tool.grep.title",
description: "settings.permissions.tool.grep.description",
},
{
id: "list",
title: "settings.permissions.tool.list.title",
description: "settings.permissions.tool.list.description",
},
{
id: "bash",
title: "settings.permissions.tool.bash.title",
description: "settings.permissions.tool.bash.description",
},
{
id: "task",
title: "settings.permissions.tool.task.title",
description: "settings.permissions.tool.task.description",
},
{
id: "skill",
title: "settings.permissions.tool.skill.title",
description: "settings.permissions.tool.skill.description",
},
{
id: "lsp",
title: "settings.permissions.tool.lsp.title",
description: "settings.permissions.tool.lsp.description",
},
{
id: "todoread",
title: "settings.permissions.tool.todoread.title",
description: "settings.permissions.tool.todoread.description",
},
{
id: "todowrite",
title: "settings.permissions.tool.todowrite.title",
description: "settings.permissions.tool.todowrite.description",
},
{
id: "webfetch",
title: "settings.permissions.tool.webfetch.title",
description: "settings.permissions.tool.webfetch.description",
},
{
id: "websearch",
title: "settings.permissions.tool.websearch.title",
description: "settings.permissions.tool.websearch.description",
},
{
id: "codesearch",
title: "settings.permissions.tool.codesearch.title",
description: "settings.permissions.tool.codesearch.description",
},
{
id: "external_directory",
title: "settings.permissions.tool.external_directory.title",
description: "settings.permissions.tool.external_directory.description",
},
{
id: "doom_loop",
title: "settings.permissions.tool.doom_loop.title",
description: "settings.permissions.tool.doom_loop.description",
},
] as const
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
function toMap(value: unknown): PermissionMap {
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
const action = getAction(value)
if (action) return { "*": action }
return {}
}
function getAction(value: unknown): PermissionAction | undefined {
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
return
}
function getRuleDefault(value: unknown): PermissionAction | undefined {
const action = getAction(value)
if (action) return action
if (!value || typeof value !== "object" || Array.isArray(value)) return
return getAction((value as Record<string, unknown>)["*"])
}
export const SettingsPermissions: Component = () => {
const globalSync = useGlobalSync()
const language = useLanguage()
const actions = createMemo(
(): Array<{ value: PermissionAction; label: string }> =>
ACTIONS.map((action) => ({
value: action.value,
label: language.t(action.label),
})),
)
const permission = createMemo(() => {
return toMap(globalSync.data.config.permission)
})
const actionFor = (id: string): PermissionAction => {
const value = permission()[id]
const direct = getRuleDefault(value)
if (direct) return direct
const wildcard = getRuleDefault(permission()["*"])
if (wildcard) return wildcard
return "allow"
}
const setPermission = async (id: string, action: PermissionAction) => {
const before = globalSync.data.config.permission
const map = toMap(before)
const existing = map[id]
const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
const rollback = (err: unknown) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
}
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
</div>
</div>
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
<div class="flex flex-col gap-2">
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
<div class="border border-border-weak-base rounded-lg overflow-hidden">
<For each={ITEMS}>
{(item) => (
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
<Select
options={actions()}
current={actions().find((o) => o.value === actionFor(item.id))}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && setPermission(item.id, option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
)}
</For>
</div>
</div>
</div>
</div>
)
}
interface SettingsRowProps {
title: string
description: string
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}

View File

@@ -11,6 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -136,7 +137,7 @@ export const SettingsProviders: Component = () => {
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<Show
when={connected().length > 0}
fallback={
@@ -169,12 +170,12 @@ export const SettingsProviders: Component = () => {
)}
</For>
</Show>
</div>
</SettingsList>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsList>
<For each={popular()}>
{(item) => (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
@@ -232,7 +233,7 @@ export const SettingsProviders: Component = () => {
{language.t("common.connect")}
</Button>
</div>
</div>
</SettingsList>
<Button
variant="ghost"

View File

@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -53,7 +53,8 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
@@ -85,15 +86,17 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
const [tick, setTick] = createSignal(0)
const [state, setState] = createStore({
url: undefined as string | undefined,
tick: 0,
})
createEffect(() => {
tick()
state.tick
let dead = false
const result = get?.()
if (!result) {
setUrl(undefined)
setState("url", undefined)
onCleanup(() => {
dead = true
})
@@ -103,7 +106,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setUrl(next ? normalizeServerUrl(next) : undefined)
setState("url", next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
@@ -111,7 +114,7 @@ const useDefaultServerKey = (
return
}
setUrl(normalizeServerUrl(result))
setState("url", normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
@@ -119,11 +122,11 @@ const useDefaultServerKey = (
return {
key: () => {
const u = url()
const u = state.url
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
refresh: () => setState("tick", (value) => value + 1),
}
}
@@ -168,7 +171,7 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const fetcher = platform.fetch ?? globalThis.fetch
const [shown, setShown] = createSignal(false)
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -176,10 +179,10 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, fetcher)
const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -199,18 +202,23 @@ export function StatusPopover() {
return (
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-6 h-6 p-0 box-border",
class: "titlebar-icon w-8 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
trigger={
<div class="flex size-4 items-center justify-center">
<div class="relative size-4">
<div class="badge-mask-tight size-4 flex items-center justify-center">
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
classList={{
"size-1.5 rounded-full": true,
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,

View File

@@ -10,6 +10,7 @@ import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { terminalAttr, terminalProbe } from "@/testing/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -17,6 +18,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void
@@ -63,6 +65,16 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorStatus = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("data" in err)) return
const data = err.data
if (!data || typeof data !== "object") return
if (!("statusCode" in data)) return
const status = data.statusCode
return typeof status === "number" ? status : undefined
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -157,8 +169,9 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
const probe = terminalProbe(id)
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -186,7 +199,11 @@ export const Terminal = (props: TerminalProps) => {
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let seek = start !== undefined ? start : restore ? -1 : 0
let output: ReturnType<typeof terminalWriter> | undefined
let drop: VoidFunction | undefined
let reconn: ReturnType<typeof setTimeout> | undefined
let tries = 0
const cleanup = () => {
if (!cleanups.length) return
@@ -217,7 +234,7 @@ export const Terminal = (props: TerminalProps) => {
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
@@ -325,6 +342,9 @@ export const Terminal = (props: TerminalProps) => {
}
onMount(() => {
probe.init()
cleanups.push(() => probe.drop())
const run = async () => {
const loaded = await loadGhostty()
if (disposed) return
@@ -352,7 +372,13 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data, done) => t.write(data, done))
output = terminalWriter((data, done) =>
t.write(data, () => {
probe.render(data)
probe.settle()
done?.()
}),
)
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -386,7 +412,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick,
})
focusTerminal()
if (local.autoFocus !== false) focusTerminal()
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
@@ -440,89 +466,136 @@ export const Terminal = (props: TerminalProps) => {
startResize()
}
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const once = { value: false }
let closing = false
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
const handleOpen = () => {
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (closing) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
const handleError = (error: Event) => {
const fail = (err: unknown) => {
if (disposed) return
if (closing) return
if (once.value) return
once.value = true
console.error("WebSocket error:", error)
local.onConnectError?.(error)
local.onConnectError?.(err)
}
socket.addEventListener("error", handleError)
const handleClose = (event: CloseEvent) => {
const gone = () =>
sdk.client.pty
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorStatus(err) === 404) return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const retry = (err: unknown) => {
if (disposed) return
if (closing) return
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
if (once.value) return
once.value = true
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
}
socket.addEventListener("close", handleClose)
if (reconn !== undefined) return
cleanups.push(() => {
closing = true
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
reconn = setTimeout(async () => {
reconn = undefined
if (disposed) return
if (await gone()) {
if (disposed) return
fail(err)
return
}
if (disposed) return
tries += 1
open()
}, ms)
}
const open = () => {
if (disposed) return
drop?.()
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(seek))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode"
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
const handleOpen = () => {
if (disposed) return
tries = 0
probe.connect()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
seek = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
seek = cursor
}
const handleError = (error: Event) => {
if (disposed) return
debugTerminal("websocket error", error)
}
const stop = () => {
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (ws === socket) ws = undefined
if (drop === stop) drop = undefined
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
}
const handleClose = (event: CloseEvent) => {
if (ws === socket) ws = undefined
if (drop === stop) drop = undefined
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (disposed) return
if (event.code === 1000) return
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
}
drop = stop
socket.addEventListener("open", handleOpen)
socket.addEventListener("message", handleMessage)
socket.addEventListener("error", handleError)
socket.addEventListener("close", handleClose)
}
probe.control({
disconnect: () => {
if (!ws) return
ws.close(4_000, "e2e")
},
})
open()
}
void run().catch((err) => {
@@ -540,6 +613,8 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (reconn !== undefined) clearTimeout(reconn)
drop?.()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => {
@@ -559,6 +634,7 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
{...{ [terminalAttr]: id }}
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background }}

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createEffect, createMemo, onCleanup, Show, 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"
@@ -58,6 +58,12 @@ export function Titlebar() {
})
const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => {
if (!params.dir) return false
if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
return parts.at(-1) === "session"
})
createEffect(() => {
const current = path()
@@ -206,19 +212,7 @@ export function Titlebar() {
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
class="group-hover/sidebar-toggle:hidden"
/>
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
@@ -231,13 +225,14 @@ export function Titlebar() {
>
<Button
variant="ghost"
icon="new-session"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</Show>
@@ -282,7 +277,7 @@ export function Titlebar() {
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
<div class="w-6 shrink-0" />
{!tauriApi() && <div class="w-36 shrink-0" />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>

View File

@@ -2,6 +2,7 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "sol
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { dict as en } from "@/i18n/en"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { Persist, persisted } from "@/utils/persist"
@@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
type KeyLabel =
| "common.key.ctrl"
| "common.key.alt"
| "common.key.shift"
| "common.key.meta"
| "common.key.space"
| "common.key.backspace"
| "common.key.enter"
| "common.key.tab"
| "common.key.delete"
| "common.key.home"
| "common.key.end"
| "common.key.pageUp"
| "common.key.pageDown"
| "common.key.insert"
| "common.key.esc"
function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
return t ? t(key) : en[key]
}
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
return id.slice(SUGGESTED_PREFIX.length)
@@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
return false
}
export function formatKeybind(config: string): string {
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
if (!config || config === "none") return ""
const keybinds = parseKeybind(config)
@@ -154,10 +176,10 @@ export function formatKeybind(config: string): string {
const kb = keybinds[0]
const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
if (kb.key) {
const keys: Record<string, string> = {
@@ -167,10 +189,29 @@ export function formatKeybind(config: string): string {
arrowright: "→",
comma: ",",
plus: "+",
space: "Space",
}
const named: Record<string, KeyLabel> = {
backspace: "common.key.backspace",
delete: "common.key.delete",
end: "common.key.end",
enter: "common.key.enter",
esc: "common.key.esc",
escape: "common.key.esc",
home: "common.key.home",
insert: "common.key.insert",
pagedown: "common.key.pageDown",
pageup: "common.key.pageUp",
space: "common.key.space",
tab: "common.key.tab",
}
const key = kb.key.toLowerCase()
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
const displayKey =
keys[key] ??
(named[key]
? keyText(named[key], t)
: key.length === 1
? key.toUpperCase()
: key.charAt(0).toUpperCase() + key.slice(1))
parts.push(displayKey)
}
@@ -364,17 +405,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
},
keybind(id: string) {
if (id === PALETTE_ID) {
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
}
const base = actionId(id)
const option = options().find((x) => actionId(x.id) === base)
if (option?.keybind) return formatKeybind(option.keybind)
if (option?.keybind) return formatKeybind(option.keybind, language.t)
const meta = catalog[base]
const config = bind(base, meta?.keybind)
if (!config) return ""
return formatKeybind(config)
return formatKeybind(config, language.t)
},
show: showPalette,
keybinds(enabled: boolean) {

View File

@@ -43,10 +43,10 @@ export {
touchFileContent,
}
function errorMessage(error: unknown) {
function errorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
return "Unknown error"
return fallback
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
.catch((e) => {
if (scope() !== directory) return
setLoadError(file, errorMessage(e))
setLoadError(file, errorMessage(e, language.t("error.chain.unknown")))
})
.finally(() => {
inflight.delete(key)

View File

@@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -14,6 +15,7 @@ const abortError = z.object({
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const language = useLanguage()
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
@@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})()
const currentServer = server.current
if (!currentServer) throw new Error("No server available")
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
const eventSdk = createSdkForServer({
signal: abort.signal,
@@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
return createSdkForServer({
server: s.http,
fetch: platform.fetch,

View File

@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test"
import {
canDisposeDirectory,
estimateRootSessionTotal,
loadRootSessionsWithFallback,
pickDirectoriesToEvict,
} from "./global-sync"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {

View File

@@ -27,8 +27,9 @@ import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
@@ -161,7 +162,9 @@ function createGlobalSync() {
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
})
const sdkFor = (directory: string) => {
@@ -189,6 +192,7 @@ function createGlobalSync() {
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
}
children.unpin(directory)
return
@@ -220,6 +224,7 @@ function createGlobalSync() {
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
sessionMeta.set(directory, { limit })
})
.catch((err) => {
@@ -400,6 +405,3 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"

View File

@@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: `Failed to reload ${project}`,
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
input.setStore("status", "partial")

View File

@@ -21,6 +21,7 @@ describe("createChildStoreManager", () => {
isLoadingSessions: () => false,
onBootstrap() {},
onDispose() {},
translate: (key) => key,
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {

View File

@@ -21,6 +21,7 @@ export function createChildStoreManager(input: {
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>()
@@ -129,7 +130,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!vcs) throw new Error("Failed to create persisted cache")
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
@@ -139,7 +140,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
if (!meta) throw new Error("Failed to create persisted project metadata")
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
@@ -148,7 +149,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as string | undefined }),
),
)
if (!icon) throw new Error("Failed to create persisted project icon")
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () =>
@@ -211,7 +212,7 @@ export function createChildStoreManager(input: {
}
mark(directory)
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
}
})
test("cleans caches for trimmed sessions on session.created", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [dropped],
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: kept } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_keep" })],
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
}),
)
cleanupDroppedSessionCaches(store, setStore, store.session)
expect(store.part.msg_1).toBeUndefined()
})
test("upserts and removes messages while clearing orphaned parts", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(

View File

@@ -13,6 +13,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
}
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
store.session_diff[sessionID] !== undefined ||
store.todo[sessionID] !== undefined ||
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
dropSessionCaches(draft, [sessionID])
}),
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
...Object.values(store.part)
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
}
case "session.deleted": {
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break

View File

@@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const part = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part[m.id]).toBeUndefined()
})
test("pickSessionCacheEvictions preserves requested sessions", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"])
const stale = pickSessionCacheEvictions({
seen,
keep: "ses_4",
limit: 2,
preserve: ["ses_1"],
})
expect(stale).toEqual(["ses_2", "ses_3"])
expect([...seen]).toEqual(["ses_1", "ses_4"])
})
})

View File

@@ -0,0 +1,62 @@
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
if (stale.size === 0) return
for (const key of Object.keys(store.part)) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
delete store.part[key]
}
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
}
}
export function pickSessionCacheEvictions(input: {
seen: Set<string>
keep: string
limit: number
preserve?: Iterable<string>
}) {
const stale: string[] = []
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
input.seen.add(input.keep)
for (const id of input.seen) {
if (input.seen.size - stale.length <= input.limit) break
if (keep.has(id)) continue
stale.push(id)
}
for (const id of stale) {
input.seen.delete(id)
}
return stale
}

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test"
import {
clearSessionPrefetch,
clearSessionPrefetchDirectory,
getSessionPrefetch,
runSessionPrefetch,
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch"
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch("/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
sessionID: "ses_1",
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"])
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch("/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
sessionID: "ses_2",
task: async () => {
calls += 1
return { limit: 100, cursor: "next", complete: true, at: 456 }
},
})
const [a, b] = await Promise.all([run(), run()])
expect(calls).toBe(1)
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
})
test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
clearSessionPrefetchDirectory("/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("refreshes stale first-page prefetched history", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 200, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(false)
})
test("keeps deeper or complete history cached", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 400, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 120, complete: true, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
})
})

View File

@@ -0,0 +1,100 @@
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
export const SESSION_PREFETCH_TTL = 15_000
type Meta = {
limit: number
cursor?: string
complete: boolean
at: number
}
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
if (input.message) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.chunk) return true
} else {
if (!input.info) return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
}
const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(directory: string, sessionID: string) {
return cache.get(key(directory, sessionID))
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(key(directory, sessionID))
}
export function clearSessionPrefetchInflight() {
inflight.clear()
}
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
return version(key(directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
const value = version(id)
const promise = input.task(value).finally(() => {
if (inflight.get(id) === promise) inflight.delete(id)
})
inflight.set(id, promise)
return promise
}
export function setSessionPrefetch(input: {
directory: string
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(key(input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
at: input.at ?? Date.now(),
})
}
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(directory: string) {
const prefix = `${directory}\n`
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}

View File

@@ -1,4 +1,4 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [from, setFrom] = createSignal<string | undefined>(undefined)
const [to, setTo] = createSignal<string | undefined>(undefined)
const [range, setRange] = createStore({
from: undefined as string | undefined,
to: undefined as string | undefined,
})
const state = { started: false }
let timer: ReturnType<typeof setTimeout> | undefined
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
if (previous === platform.version) return
setFrom(previous)
setTo(platform.version)
setRange({ from: previous, to: platform.version })
start(previous)
})
return {
ready,
from,
to,
from: () => range.from,
to: () => range.to,
get last() {
return store.version
},

View File

@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
open: createMemo(() => s().reviewOpen),
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()
const next = Array.from(new Set(open))
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
reviewOpen: next,
})
return
}
if (same(current.reviewOpen, next)) return
setStore("sessionView", session, "reviewOpen", next)
},
openPath(path: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
reviewOpen: open,
reviewOpen: [path],
})
return
}
if (same(current.reviewOpen, open)) return
setStore("sessionView", session, "reviewOpen", open)
if (!current.reviewOpen) {
setStore("sessionView", session, "reviewOpen", [path])
return
}
if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
},
}

View File

@@ -1,66 +0,0 @@
type NotificationIndexItem = {
directory?: string
session?: string
viewed: boolean
type: string
}
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
const sessionAll = new Map<string, T[]>()
const sessionUnseen = new Map<string, T[]>()
const sessionUnseenCount = new Map<string, number>()
const sessionUnseenHasError = new Map<string, boolean>()
const projectAll = new Map<string, T[]>()
const projectUnseen = new Map<string, T[]>()
const projectUnseenCount = new Map<string, number>()
const projectUnseenHasError = new Map<string, boolean>()
for (const notification of list) {
const session = notification.session
if (session) {
const all = sessionAll.get(session)
if (all) all.push(notification)
else sessionAll.set(session, [notification])
if (!notification.viewed) {
const unseen = sessionUnseen.get(session)
if (unseen) unseen.push(notification)
else sessionUnseen.set(session, [notification])
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
if (notification.type === "error") sessionUnseenHasError.set(session, true)
}
}
const directory = notification.directory
if (directory) {
const all = projectAll.get(directory)
if (all) all.push(notification)
else projectAll.set(directory, [notification])
if (!notification.viewed) {
const unseen = projectUnseen.get(directory)
if (unseen) unseen.push(notification)
else projectUnseen.set(directory, [notification])
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
if (notification.type === "error") projectUnseenHasError.set(directory, true)
}
}
}
return {
session: {
all: sessionAll,
unseen: sessionUnseen,
unseenCount: sessionUnseenCount,
unseenHasError: sessionUnseenHasError,
},
project: {
all: projectAll,
unseen: projectUnseen,
unseenCount: projectUnseenCount,
unseenHasError: projectUnseenHasError,
},
}
}

View File

@@ -1,73 +0,0 @@
import { describe, expect, test } from "bun:test"
import { buildNotificationIndex } from "./notification-index"
type Notification = {
type: "turn-complete" | "error"
session: string
directory: string
viewed: boolean
time: number
}
const turn = (session: string, directory: string, viewed = false): Notification => ({
type: "turn-complete",
session,
directory,
viewed,
time: 1,
})
const error = (session: string, directory: string, viewed = false): Notification => ({
type: "error",
session,
directory,
viewed,
time: 1,
})
describe("buildNotificationIndex", () => {
test("builds unseen counts and unseen error flags", () => {
const list = [
turn("s1", "d1", false),
error("s1", "d1", false),
turn("s1", "d1", true),
turn("s2", "d1", false),
error("s3", "d2", true),
]
const index = buildNotificationIndex(list)
expect(index.session.all.get("s1")?.length).toBe(3)
expect(index.session.unseen.get("s1")?.length).toBe(2)
expect(index.session.unseenCount.get("s1")).toBe(2)
expect(index.session.unseenHasError.get("s1")).toBe(true)
expect(index.session.unseenCount.get("s2")).toBe(1)
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
expect(index.project.unseenCount.get("d1")).toBe(3)
expect(index.project.unseenHasError.get("d1")).toBe(true)
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
})
test("updates selectors after viewed transitions", () => {
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
const before = buildNotificationIndex(list)
const after = buildNotificationIndex(next)
expect(before.session.unseenCount.get("s1")).toBe(2)
expect(before.session.unseenHasError.get("s1")).toBe(true)
expect(before.project.unseenCount.get("d1")).toBe(3)
expect(before.project.unseenHasError.get("d1")).toBe(true)
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
expect(after.project.unseenCount.get("d1")).toBe(1)
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
})
})

View File

@@ -1,6 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -58,10 +59,10 @@ export type Platform = {
fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */
getDefaultServerUrl?(): Promise<string | null>
getDefaultServer?(): Promise<ServerConnection.Key | null>
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>

View File

@@ -1,9 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -96,7 +95,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const platform = usePlatform()
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
createEffect(() => {
const current_ = current()

View File

@@ -22,6 +22,7 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -45,6 +46,7 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
showReasoningSummaries: false,
shellToolPartsExpanded: true,
editToolPartsExpanded: false,
@@ -126,6 +128,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,

View File

@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string): Message => ({
id,
@@ -11,7 +13,7 @@ const userMessage = (id: string, sessionID: string): Message => ({
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Part => ({
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id,
sessionID,
messageID,
@@ -53,4 +55,69 @@ describe("sync optimistic reducers", () => {
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
complete: true,
},
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
})

View File

@@ -3,9 +3,16 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
@@ -25,6 +32,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -41,6 +54,67 @@ type OptimisticRemoveInput = {
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
@@ -108,8 +182,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
@@ -121,44 +199,167 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
complete: session.length < input.limit,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((next) => {
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
input.setStore("part", p.id, p.part)
}
setMeta("limit", key, input.limit)
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta("loading", key, false)
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
@@ -185,11 +386,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
@@ -211,73 +416,112 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
variant: input.variant,
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string) {
async sync(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const limit = meta.limit[key] ?? messagePageSize
touch(directory, setStore, sessionID)
const sessionReq = hasSession
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
const messagesReq = loadMessages({
directory,
client,
setStore,
sessionID,
limit,
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? messagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
async diff(sessionID: string) {
async diff(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.session_diff[sessionID] !== undefined) return
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}),
)
},
async todo(sessionID: string) {
async todo(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
if (!opts?.force) return
}
if (cached !== undefined) {
@@ -287,6 +531,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
@@ -300,7 +545,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return true
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
@@ -310,21 +555,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
const currentLimit = meta.limit[key] ?? messagePageSize
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: currentLimit + step,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client

View File

@@ -0,0 +1,51 @@
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {
return numbered.some((text) => title === text.replace("{{number}}", String(number)))
}
export function titleNumber(title: string, max: number) {
return Array.from({ length: max }, (_, idx) => idx + 1).find((number) => isDefaultTitle(title, number))
}

View File

@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
@@ -17,6 +18,7 @@ beforeAll(async () => {
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
])
})
})
describe("migrateTerminalState", () => {
test("drops invalid terminals and restores a valid active terminal", () => {
expect(
migrateTerminalState({
active: "missing",
all: [
null,
{ id: "one", title: "Terminal 2" },
{ id: "one", title: "duplicate", titleNumber: 9 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
{ title: "no-id" },
],
}),
).toEqual({
active: "one",
all: [
{ id: "one", title: "Terminal 2", titleNumber: 2 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
],
})
})
test("keeps a valid active id", () => {
expect(
migrateTerminalState({
active: "two",
all: [
{ id: "one", title: "Terminal 1" },
{ id: "two", title: "shell", titleNumber: 7 },
],
}),
).toEqual({
active: "two",
all: [
{ id: "one", title: "Terminal 1", titleNumber: 1 },
{ id: "two", title: "shell", titleNumber: 7 },
],
})
})
})

View File

@@ -1,9 +1,10 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
export type LocalPTY = {
@@ -20,6 +21,67 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function text(value: unknown) {
return typeof value === "string" ? value : undefined
}
function num(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
function pty(value: unknown): LocalPTY | undefined {
if (!record(value)) return
const id = text(value.id)
if (!id) return
const title = text(value.title) ?? ""
const number = num(value.titleNumber)
const rows = num(value.rows)
const cols = num(value.cols)
const buffer = text(value.buffer)
const scrollY = num(value.scrollY)
const cursor = num(value.cursor)
return {
id,
title,
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
...(rows !== undefined ? { rows } : {}),
...(cols !== undefined ? { cols } : {}),
...(buffer !== undefined ? { buffer } : {}),
...(scrollY !== undefined ? { scrollY } : {}),
...(cursor !== undefined ? { cursor } : {}),
}
}
export function migrateTerminalState(value: unknown) {
if (!record(value)) return value
const seen = new Set<string>()
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
const next = pty(item)
if (!next || seen.has(next.id)) return []
seen.add(next.id)
return [next]
})
const active = text(value.active)
return {
active: active && seen.has(active) ? active : all[0]?.id,
all,
}
}
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
@@ -38,6 +100,16 @@ type TerminalCacheEntry = {
const caches = new Set<Map<string, TerminalCacheEntry>>()
const trimTerminal = (pty: LocalPTY) => {
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
return {
...pty,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
@@ -61,16 +133,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const numberFromTitle = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
const [store, setStore, _, ready] = persisted(
Persist.workspace(dir, "terminal", legacy),
{
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
active?: string
all: LocalPTY[]
@@ -118,26 +185,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
onCleanup(unsub)
const meta = { migrated: false }
createEffect(() => {
if (!ready()) return
if (meta.migrated) return
meta.migrated = true
setStore("all", (all) => {
const next = all.map((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return pty
const parsed = numberFromTitle(pty.title)
if (parsed === undefined) return pty
return { ...pty, titleNumber: parsed }
})
if (next.every((pty, index) => pty === all[index])) return all
return next
})
})
return {
ready,
all: createMemo(() => store.all),
@@ -152,13 +199,13 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
const nextNumber = pickNextTerminalNumber()
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
const newTerminal = {
id,
title: pty.data?.title ?? "Terminal",
title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
setStore("all", store.all.length, newTerminal)
@@ -188,6 +235,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
console.error("Failed to update terminal", error)
})
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
if (index === -1) return
setStore("all", index, (pty) => trimTerminal(pty))
},
trimAll() {
setStore("all", (all) => {
const next = all.map(trimTerminal)
if (next.every((pty, index) => pty === all[index])) return all
return next
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
@@ -322,12 +381,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),
)
return {
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),

View File

@@ -1,6 +1,5 @@
// @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -98,6 +97,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
const getDefaultUrl = () => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
return getCurrentUrl()
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -106,26 +118,24 @@ const platform: Platform = {
forward,
restart,
notify,
getDefaultServerUrl: async () => readDefaultServerUrl(),
setDefaultServerUrl: writeDefaultServerUrl,
getDefaultServer: async () => {
const stored = readDefaultServerUrl()
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
servers={[server]}
disableHealthCheck
/>
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders() {
const globalSync = useGlobalSync()
const params = useParams()
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
const providers = createMemo(() => {
if (currentDirectory()) {
const [projectStore] = globalSync.child(currentDirectory())
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
return projectStore.provider
}
return globalSync.data.provider
})
const connectedIDs = createMemo(() => new Set(providers().connected))
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
const paid = createMemo(() =>
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
)
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
}
return {
all: createMemo(() => providers().all),
default: createMemo(() => providers().default),
popular,
connected,
paid,
all: () => providers().all,
default: () => providers().default,
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
connected: () => {
const connected = new Set(providers().connected)
return providers().all.filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
return providers().all.filter(
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
)
},
}
}

View File

@@ -104,6 +104,7 @@ export const dict = {
"dialog.model.empty": "لا توجد نتائج للنماذج",
"dialog.model.manage": "إدارة النماذج",
"dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.",
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
@@ -243,7 +244,7 @@ export const dict = {
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
"prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة",
@@ -256,8 +257,8 @@ export const dict = {
"prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
"prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
"prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",
@@ -288,6 +289,11 @@ export const dict = {
"dialog.server.add.error": "تعذر الاتصال بالخادم",
"dialog.server.add.checking": "جارٍ التحقق...",
"dialog.server.add.button": "إضافة خادم",
"dialog.server.add.name": "اسم الخادم (اختياري)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "اسم المستخدم (اختياري)",
"dialog.server.add.password": "كلمة المرور (اختياري)",
"dialog.server.edit.title": "تحرير الخادم",
"dialog.server.default.title": "الخادم الافتراضي",
"dialog.server.default.description":
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@@ -358,6 +364,7 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "لغة",
"toast.language.description": "تم التبديل إلى {{language}}",
"toast.theme.title": "تم تبديل السمة",
@@ -444,8 +451,11 @@ export const dict = {
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
"session.review.noChanges": "لا توجد تغييرات",
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات",
"session.files.empty": "لا توجد ملفات",
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
@@ -456,6 +466,17 @@ export const dict = {
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
"session.followupDock.sendNow": "إرسال الآن",
"session.followupDock.edit": "تحرير",
"session.followupDock.collapse": "طي الرسائل المنتظرة",
"session.followupDock.expand": "توسيع الرسائل المنتظرة",
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
"session.revertDock.restore": "استعادة الرسالة",
"session.new.title": "ابنِ أي شيء",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
@@ -538,10 +559,18 @@ export const dict = {
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.appearance.title": "المظهر",
"settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
"settings.general.row.colorScheme.title": "مخطط الألوان",
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
"settings.general.row.theme.title": "السمة",
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "الخط",
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
"settings.general.row.followup.title": "سلوك المتابعة",
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
"settings.general.row.followup.option.queue": "قائمة انتظار",
"settings.general.row.followup.option.steer": "توجيه",
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
"settings.general.row.shellToolPartsExpanded.description":
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
@@ -749,4 +778,77 @@ export const dict = {
"common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"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",
"session.header.open.fileExplorer": "مستكشف الملفات",
"session.header.open.fileManager": "مدير الملفات",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "المحطة الطرفية",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "تشخيص أداء التطوير",
"debugBar.na": "غير متاح",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "الإطارات التي تزيد عن 32 مللي ثانية في آخر 5 ثوانٍ.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "الوقت المحظور وعدد المهام الطويلة في آخر 5 ثوانٍ. أقصى مهمة: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "أسوأ تأخير إدخال تمت ملاحظته في آخر 5 ثوانٍ.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "غير معروف",
"error.page.circular": "[دائري]",
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
}

View File

@@ -104,6 +104,7 @@ export const dict = {
"dialog.model.empty": "Nenhum resultado de modelo",
"dialog.model.manage": "Gerenciar modelos",
"dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.",
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
"dialog.provider.viewAll": "Ver mais provedores",
@@ -243,7 +244,7 @@ export const dict = {
"prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
"prompt.popover.emptyResults": "Nenhum resultado correspondente",
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
"prompt.dropzone.label": "Arraste imagens, PDFs ou arquivos de texto aqui",
"prompt.dropzone.file.label": "Solte para @mencionar arquivo",
"prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill",
@@ -256,8 +257,8 @@ export const dict = {
"prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Colagem não suportada",
"prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
"prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",
@@ -288,6 +289,11 @@ export const dict = {
"dialog.server.add.error": "Não foi possível conectar ao servidor",
"dialog.server.add.checking": "Verificando...",
"dialog.server.add.button": "Adicionar",
"dialog.server.add.name": "Nome do servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nome de usuário (opcional)",
"dialog.server.add.password": "Senha (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor padrão",
"dialog.server.default.description":
"Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.",
@@ -359,6 +365,7 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Idioma",
"toast.language.description": "Alterado para {{language}}",
"toast.theme.title": "Tema alterado",
@@ -446,9 +453,13 @@ export const dict = {
"session.review.change.other": "Alterações",
"session.review.loadingChanges": "Carregando alterações...",
"session.review.empty": "Nenhuma alteração nesta sessão ainda",
"session.review.noVcs": "Nenhum Sistema de Controle de Versão Git detectado, alterações não exibidas",
"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.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos",
"session.files.empty": "Nenhum arquivo",
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
@@ -459,6 +470,17 @@ export const dict = {
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.followupDock.summary.one": "{{count}} mensagem na fila",
"session.followupDock.summary.other": "{{count}} mensagens na fila",
"session.followupDock.sendNow": "Enviar agora",
"session.followupDock.edit": "Editar",
"session.followupDock.collapse": "Recolher mensagens na fila",
"session.followupDock.expand": "Expandir mensagens na fila",
"session.revertDock.summary.one": "{{count}} mensagem revertida",
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
"session.revertDock.collapse": "Recolher mensagens revertidas",
"session.revertDock.expand": "Expandir mensagens revertidas",
"session.revertDock.restore": "Restaurar mensagem",
"session.new.title": "Crie qualquer coisa",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
@@ -544,10 +566,19 @@ export const dict = {
"settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
"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",
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte",
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
"settings.general.row.followup.description":
"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.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",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
@@ -757,4 +788,79 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}d atrás",
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
"settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.",
"app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...",
"app.server.otherServers": "Outros servidores",
"dialog.server.add.usernamePlaceholder": "nome de usuário",
"dialog.server.add.passwordPlaceholder": "senha",
"server.row.noUsername": "sem nome de usuário",
"session.review.noVcs.createGit.title": "Criar um repositório Git",
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
"session.review.noVcs.createGit.action": "Criar repositório Git",
"session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
"session.question.progress": "{{current}} de {{total}} perguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de Arquivos",
"session.header.open.fileManager": "Gerenciador de Arquivos",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnóstico de desempenho de desenvolvimento",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Última transição de rota concluída tocando em uma página de sessão, medida desde o início do roteador até a primeira pintura após o estabelecimento.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Quadros por segundo nos últimos 5 segundos.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Pior tempo de quadro nos últimos 5 segundos.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Quadros acima de 32ms nos últimos 5 segundos.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Tempo bloqueado e contagem de tarefas longas nos últimos 5 segundos. Tarefa máx: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Pior atraso de entrada observado nos últimos 5 segundos.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Duração aproximada da interação nos últimos 5 segundos. Isso é semelhante ao INP, não o INP oficial do Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Mudança cumulativa de layout para o tempo de vida atual do aplicativo.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
"debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Espaço",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "desconhecido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
}

View File

@@ -113,6 +113,7 @@ export const dict = {
"dialog.model.empty": "Nema rezultata za modele",
"dialog.model.manage": "Upravljaj modelima",
"dialog.model.manage.description": "Prilagodi koji se modeli prikazuju u izborniku modela.",
"dialog.model.manage.provider.toggle": "Uključi/isključi sve {{provider}} modele",
"dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
"dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
@@ -263,7 +264,7 @@ export const dict = {
"prompt.popover.emptyResults": "Nema rezultata",
"prompt.popover.emptyCommands": "Nema komandi",
"prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
"prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
"prompt.slash.badge.custom": "prilagođeno",
"prompt.slash.badge.skill": "skill",
@@ -277,8 +278,8 @@ export const dict = {
"prompt.action.send": "Pošalji",
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
"prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",
@@ -315,6 +316,11 @@ export const dict = {
"dialog.server.add.error": "Nije moguće povezati se na server",
"dialog.server.add.checking": "Provjera...",
"dialog.server.add.button": "Dodaj server",
"dialog.server.add.name": "Ime servera (opcionalno)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Korisničko ime (opcionalno)",
"dialog.server.add.password": "Lozinka (opcionalno)",
"dialog.server.edit.title": "Uredi server",
"dialog.server.default.title": "Podrazumijevani server",
"dialog.server.default.description":
"Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potreban je restart.",
@@ -393,6 +399,7 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Jezik",
"toast.language.description": "Prebačeno na {{language}}",
@@ -498,10 +505,14 @@ export const dict = {
"session.review.change.other": "Izmjene",
"session.review.loadingChanges": "Učitavanje izmjena...",
"session.review.empty": "Još nema izmjena u ovoj sesiji",
"session.review.noVcs": "Nije detektovan Git sistem kontrole verzija, promjene se ne prikazuju",
"session.review.noSnapshot":
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
"session.review.noChanges": "Nema izmjena",
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
"session.files.all": "Sve datoteke",
"session.files.empty": "Nema datoteka",
"session.files.binaryContent": "Binarna datoteka (sadržaj se ne može prikazati)",
"session.messages.renderEarlier": "Prikaži ranije poruke",
@@ -514,6 +525,17 @@ export const dict = {
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
"session.followupDock.sendNow": "Pošalji sada",
"session.followupDock.edit": "Uredi",
"session.followupDock.collapse": "Sažmi poruke na čekanju",
"session.followupDock.expand": "Proširi poruke na čekanju",
"session.revertDock.summary.one": "{{count}} vraćena poruka",
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
"session.revertDock.collapse": "Sažmi vraćene poruke",
"session.revertDock.expand": "Proširi vraćene poruke",
"session.revertDock.restore": "Vrati poruku",
"session.new.title": "Napravi bilo šta",
"session.new.worktree.main": "Glavna grana",
@@ -609,10 +631,18 @@ export const dict = {
"settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u",
"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",
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
"settings.general.row.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.reasoningSummaries.title": "Prikaži sažetke rasuđivanja",
"settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci",
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
"settings.general.row.shellToolPartsExpanded.description":
@@ -834,4 +864,79 @@ export const dict = {
"common.time.daysAgo.short": "prije {{count}} d",
"settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja",
"settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...",
"app.server.otherServers": "Drugi serveri",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.passwordPlaceholder": "lozinka",
"server.row.noUsername": "nema korisničkog imena",
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
"session.question.progress": "{{current}} od {{total}} pitanja",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Dijagnostika performansi razvoja",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Posljednji završeni prelazak rute koji dotiče stranicu sesije, mjeren od početka rutera do prvog iscrtavanja nakon smirivanja.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Kadrovi u sekundi tokom posljednjih 5 sekundi.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Najgore vrijeme kadra u posljednjih 5 sekundi.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Kadrovi duži od 32ms u posljednjih 5 sekundi.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokirano vrijeme i broj dugih zadataka u posljednjih 5 sekundi. Maks zadatak: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Najgore zabilježeno kašnjenje unosa u posljednjih 5 sekundi.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Približno trajanje interakcije tokom posljednjih 5 sekundi. Ovo je slično INP-u, nije službeni Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativni pomak rasporeda za trenutni životni vijek aplikacije.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
"debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "nepoznato",
"error.page.circular": "[Kružno]",
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
}

View File

@@ -113,6 +113,7 @@ export const dict = {
"dialog.model.empty": "Ingen modeller fundet",
"dialog.model.manage": "Administrer modeller",
"dialog.model.manage.description": "Tilpas hvilke modeller der vises i modelvælgeren.",
"dialog.model.manage.provider.toggle": "Skift alle {{provider}}-modeller",
"dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
"dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
@@ -261,7 +262,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
"prompt.dropzone.label": "Slip billeder, PDF'er eller tekstfiler her",
"prompt.dropzone.file.label": "Slip for at @nævne fil",
"prompt.slash.badge.custom": "brugerdefineret",
"prompt.slash.badge.skill": "skill",
@@ -275,8 +276,8 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet indsæt",
"prompt.toast.pasteUnsupported.description": "Kun billeder eller PDF'er kan indsættes her.",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",
@@ -313,6 +314,11 @@ export const dict = {
"dialog.server.add.error": "Kunne ikke forbinde til server",
"dialog.server.add.checking": "Tjekker...",
"dialog.server.add.button": "Tilføj server",
"dialog.server.add.name": "Servernavn (valgfrit)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Brugernavn (valgfrit)",
"dialog.server.add.password": "Adgangskode (valgfrit)",
"dialog.server.edit.title": "Rediger server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
@@ -391,6 +397,7 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Sprog",
"toast.language.description": "Skiftede til {{language}}",
@@ -495,9 +502,13 @@ export const dict = {
"session.review.change.other": "Ændringer",
"session.review.loadingChanges": "Indlæser ændringer...",
"session.review.empty": "Ingen ændringer i denne session endnu",
"session.review.noVcs": "Intet Git versionsstyringssystem fundet, ændringer vises ikke",
"session.review.noSnapshot":
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
"session.review.noChanges": "Ingen ændringer",
"session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer",
"session.files.empty": "Ingen filer",
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere beskeder",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
@@ -509,6 +520,17 @@ export const dict = {
"session.todo.title": "Opgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.followupDock.summary.one": "{{count}} besked i kø",
"session.followupDock.summary.other": "{{count}} beskeder i kø",
"session.followupDock.sendNow": "Send nu",
"session.followupDock.edit": "Rediger",
"session.followupDock.collapse": "Skjul beskeder i kø",
"session.followupDock.expand": "Udvid beskeder i kø",
"session.revertDock.summary.one": "{{count}} tilbagerullet besked",
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
"session.revertDock.restore": "Gendan besked",
"session.new.title": "Byg hvad som helst",
"session.new.worktree.main": "Hovedgren",
@@ -604,10 +626,18 @@ export const dict = {
"settings.general.row.language.description": "Ændr visningssproget for OpenCode",
"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",
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
"settings.general.row.font.title": "Skrifttype",
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
"settings.general.row.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.reasoningSummaries.title": "Vis tænkeoversigter",
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
@@ -828,4 +858,79 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}d siden",
"settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler",
"settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver igen automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brugernavn",
"dialog.server.add.passwordPlaceholder": "adgangskode",
"server.row.noUsername": "intet brugernavn",
"session.review.noVcs.createGit.title": "Opret et Git-repository",
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
"session.review.noVcs.createGit.action": "Opret Git-repository",
"session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
"session.question.progress": "{{current}} af {{total}} spørgsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Stifinder",
"session.header.open.fileManager": "Filhåndtering",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Udviklingsydelsesdiagnostik",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Værste observerede inputforsinkelse i de sidste 5 sekunder.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Omtrentlig interaktionsvarighed over de sidste 5 sekunder. Dette er INP-lignende, ikke den officielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.",
"debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Mellemrum",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ukendt",
"error.page.circular": "[Cirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
"error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
}

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