Compare commits

..
Author SHA1 Message Date
Aiden Cline c92541a40d refactor(codemode): check the Headers receiver before calling the host 2026-09-18 01:05:10 -05:00
Aiden Cline ee7317fbcf test(codemode): keep only the Headers WPT cases that exercise the runtime 2026-09-18 01:05:09 -05:00
Aiden Cline 2f8f4975f3 fix(codemode): let Headers methods report a bad receiver 2026-09-18 01:05:09 -05:00
Aiden Cline c91b330887 docs(codemode): replace the stale Values section with Extension.make 2026-09-18 01:05:09 -05:00
Aiden Cline 7b01b0224a feat(codemode): add the Headers built-in 2026-09-18 01:05:09 -05:00
Luke Parker d4303a9ca5 feat(app): show provider metrics in debug bar (#49674) 2026-09-18 15:51:08 +10:00
Kit Langton 735556eab8 fix(tui): open completed subagent sessions (#49675) 2026-09-18 05:42:40 +00:00
Kit Langton 10cac9ab5d fix(tui): show update installation progress (#49676) 2026-09-17 22:33:43 -07:00
Kit Langton 2d0ce64111 fix(client): reconcile stale tools at settlement (#49672) 2026-09-17 22:23:45 -07:00
Luke Parker 3355c93efd fix(app): create local sessions in project root (#49668) 2026-09-18 14:12:18 +10:00
Luke Parker 469e1c035e feat(app): stream large attachments to the server with progress (#49647) 2026-09-18 14:05:30 +10:00
opencode-agent[bot]andrekram1-node b2e3569add test(codemode): isolate OpenAPI fixtures (#49667)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-17 23:01:36 -05:00
James Long 0ac458b3b3 refactor(theme): rename surface tokens to raised (#49655) 2026-09-17 22:05:42 -04:00
Brendan DukeandAiden Cline b278ef6b82 fix(tui): include reasoning tokens in throughput (#48689)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-17 20:12:06 -05:00
Dax 3e3a4ae46b feat(tui): add /btw side question command (#49646) 2026-09-17 21:03:09 -04:00
Dax Raad fe0d9579a7 fix(tui): preserve block tool spacing 2026-09-17 20:18:52 -04:00
Aiden Cline a2594ddefb fix(tui): sync model preferences across clients (#49611) 2026-09-17 14:54:34 -05:00
Aiden Cline 3555cef1c8 chore(client): regenerate policy types (#49613) 2026-09-17 14:38:09 -05:00
opencode 0fa881efd0 sync release versions for v2.0.7 2026-09-17 19:29:59 +00:00
opencode-agent[bot]andrekram1-node 37ff501cc6 fix(cli): keep updates client-owned (#49577)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-17 12:47:49 -04:00
Dax fa126d68e8 feat(core): enforce permission policies 2026-09-17 12:39:20 -04:00
opencode-agent[bot]andJames Long a0571a8b14 fix(tui): keep upgrade as a search keyword (#49567)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-17 11:32:16 -04:00
Dax Raad cae475df37 sync 2026-09-17 10:47:52 -04:00
Shoubhit Dash 19781a4447 fix(acp): propagate request cancellation and close sessions cleanly (#49563) 2026-09-17 20:16:07 +05:30
Brendan Allan e32de90d88 fix(app): prevent settings project cache reload loop (#49564) 2026-09-17 14:45:03 +00:00
Kit Langton 0ee5ef6c43 fix(tui): keep spinner glyph from shrinking beside long labels (#49395) 2026-09-17 07:43:07 -07:00
a72f988c90 fix(tui): switch vertical tabs sooner (#49440)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-17 09:43:02 -05:00
Dax Raad 2a132d6f50 fix(tui): include provider latency in TPS 2026-09-17 10:40:12 -04:00
Dax Raad 23418b3885 docs: reorder sidebar navigation 2026-09-17 10:20:57 -04:00
Dax Raad b4ac938c3d docs: improve installation guide 2026-09-17 10:10:20 -04:00
Dax Raad 4b25739e93 docs(cli): drop command-name leads in command descriptions 2026-09-17 09:44:31 -04:00
Dax Raad 2f97e1324f fix(www): restore OpenTUI Mono docs font 2026-09-17 09:31:40 -04:00
Dax Raad 0018f08832 feat(cli): add reload command 2026-09-17 09:21:41 -04:00
Dax Raad b52f241294 feat(location): reload configuration 2026-09-17 09:18:51 -04:00
Dax Raad 254ace8594 docs: add CLI settings reference 2026-09-17 09:17:14 -04:00
usrnk1 657f700f5d feat(desktop): disable security code autofill (#49531) 2026-09-17 20:35:14 +08:00
Victor Navarro 7e1d28b549 feat(cli): support custom Console logins (#49542) 2026-09-17 13:50:32 +02:00
opencode 040d2ffb48 sync release versions for v2.0.6 2026-09-17 11:17:33 +00:00
usrnk1 0675975f98 feat(desktop): remove pointer cursor from non-link elements (#49323) 2026-09-17 20:41:24 +10:00
opencode-agent[bot]andiamdavidhill 130232feb6 chore(desktop): update bundled themes (#48713)
Co-authored-by: iamdavidhill <iamdavidhill@users.noreply.github.com>
2026-09-17 20:40:07 +10:00
opencode-agent[bot]andiamdavidhill 54f2356166 fix(app): remove Anthropic Pro and Max copy (#49369)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-17 20:39:39 +10:00
David Hill ac0dd75c6a fix(app): hide browser when the right panel closes (#49425) 2026-09-17 20:39:02 +10:00
David Hill 430886142b fix(app): center start screen beside summary panels (#49429) 2026-09-17 20:38:36 +10:00
David Hill 5a02ec776e fix(app): use muted worktree icon in session timeline (#49459) 2026-09-17 20:37:37 +10:00
David Hill c303a11725 fix(app): mute session tab hover and active backgrounds (#49465) 2026-09-17 20:37:13 +10:00
David Hill dfceca8de7 fix(app): refine start-screen logo (#49475) 2026-09-17 20:36:45 +10:00
Luke Parker f04c3fd82b feat(app): attach any file and deliver unsupported ones by path (#49467) 2026-09-17 20:35:00 +10:00
Luke Parker 1685e70c4b feat(server): add fs.write endpoint (#49466) 2026-09-17 20:17:14 +10:00
Chris Yang 73e7b7bc79 fix(app): derive directory names from listed paths (#49122) 2026-09-17 18:46:07 +10:00
David Hill a5b3802ca3 fix(app): keep compact tab icons visible (#49460) 2026-09-17 14:48:47 +08:00
Dax Raad f28d1b44e3 fix(core): serialize MCP endpoint startup 2026-09-17 00:52:34 -04:00
Dax Raad b81be469c8 fix(client): preserve state during location reload 2026-09-17 00:52:34 -04:00
opencode-agent[bot]andkitlangton fca4a8fa3b feat(tui): show explicit subagent model (#49388)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-09-16 22:50:28 -05:00
Aiden Cline 8520617ca8 fix(core): retry provider failures for about 84s with 10s gap cap (#49441) 2026-09-16 20:59:16 -05:00
Dax Raad ab3566ab82 feat(server): expose server info endpoint 2026-09-16 21:43:58 -04:00
Dax Raad b642c1d9ea docs: add CLI settings reference 2026-09-16 21:18:35 -04:00
David HillandLukeParkerDev c8cc984aa0 feat(app): improve project settings and actions (#49423)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-17 00:39:21 +00:00
Luke Parker 5a9448acbb fix(app): retry transport errors wrapped by the client (#49426) 2026-09-17 10:34:52 +10:00
David Hill f6604cd367 feat(desktop): add animated first-launch loading screen (#49408) 2026-09-17 10:02:09 +10:00
Luke Parker acfacede2d fix(app): preserve native clipboard paste (#49424) 2026-09-17 09:47:00 +10:00
Aiden Cline 4a27842fe6 fix(ai): classify gateway account limits as quota and keep 4xx non-retryable (#49195) 2026-09-16 16:14:09 -05:00
Aiden Cline d797722187 feat(codemode): carry cause and own data across the error boundary (#49390) 2026-09-16 15:58:05 -05:00
Dax 7390832f13 feat(tui): reload all locations
Add a global location reload endpoint, shared shutdown lifecycle, client resynchronization, and the TUI /reload command.\n\nfrom OpenCode
2026-09-16 16:47:44 -04:00
Kit Langton 7689c3654e fix(tui): hide error hint when MCP Enter starts sign-in (#49403) 2026-09-16 19:33:08 +00:00
Dax 7df0935ada fix(tui): apply model selection on blank submit (#49374)
Apply the selected model on blank Enter and simplify shared prompt submission setup and recovery.\n\nfrom OpenCode
2026-09-16 19:32:45 +00:00
Dax bcd43760df feat(cli): support inline config content (#49399) 2026-09-16 19:18:55 +00:00
Aiden Cline 9073c522ef feat(plugin): add experimental WebSocket send and receive hooks (#49136) 2026-09-16 14:13:33 -05:00
opencode-agent[bot]andrekram1-node 04c296310e fix(core): skip session warming for subagents (#49387)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-16 13:58:40 -05:00
Kit Langton 79d657b8fe fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:43 -04:00
Kit Langton 606ec4fa38 fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:00 -04:00
Shoubhit Dash 2cdd938152 fix: honor provider transport overrides and preserve errors (#49350) 2026-09-16 22:42:23 +05:30
opencode 4d73b40e59 sync release versions for v2.0.5 2026-09-16 16:35:05 +00:00
opencode-agent[bot]andadamdotdevin 276f2eef3e fix(stats): hide Union Alpha provider (#49355)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 10:58:41 -05:00
Dax Raad 8806d34ed4 feat(tui): make notification and sound alerts independent toggles
Remove the attention.enabled master switch. Notifications and sounds are
now enabled independently and both default to off. Existing enabled keys
are ignored; there is no migration.
2026-09-16 11:39:09 -04:00
Shoubhit Dash cda2bc5100 test(core): match the one minute Console config refresh interval (#49327) 2026-09-16 17:37:14 +05:30
Daniel Polito ba753b383f fix(core): retry acknowledged websocket read failures (#49183) 2026-09-16 16:15:19 +05:30
Simon Klee eb052f70b4 fix: mini reconnect (#49301)
A stalled catalog refresh kept reconnection from completing and prevented
new prompts. Allow the stream to reconnect independently of catalog state.
2026-09-16 08:05:23 +00:00
Victor Navarro 5eaed68c2b fix(core): refresh Console config every minute (#49233) 2026-09-16 09:28:29 +02:00
Aiden Cline 7d7962b2fa feat(core): let subagents pick a model and expose models tool (#49194) 2026-09-16 01:16:55 -05:00
Aiden Cline 0537fd47c7 feat(tui): open execute call details dialog on click (#49259) 2026-09-16 01:10:08 -05:00
Aiden Cline 9add9384be refactor(core): list which extension calls show under execute (#49281) 2026-09-16 00:30:54 -05:00
Luke Parker 005522786f fix(app): enable debug tools toggle on local channel (#49278) 2026-09-16 04:58:54 +00:00
Dax 5379a2dfe0 fix(codemode): reuse tool discovery catalogs
from OpenCode
2026-09-16 00:52:00 -04:00
Aiden Cline 6ee156e0b9 fix(core): keep the configured MCP URL as the OAuth resource (#49241) 2026-09-15 23:50:36 -05:00
opencode 7c3c50bac2 sync release versions for v2.0.4 2026-09-16 04:41:12 +00:00
Aiden Cline 194a006932 feat(core): expose fetch to code mode scripts (#49235) 2026-09-15 23:29:22 -05:00
Dax Raad 3c1f87d2d4 fix(tui): limit shell command previews 2026-09-16 00:21:05 -04:00
Aiden Cline c57733a647 refactor(codemode): before/after hooks for tool and extension calls (#49242) 2026-09-15 23:05:06 -05:00
Dax 9bf5faa978 fix(core): reuse model catalog across credential changes (#49255) 2026-09-15 23:10:29 -04:00
Dax a062f4e803 test(core): expect the vcs provider in info results (#49264) 2026-09-15 23:01:16 -04:00
opencode-agent[bot]andrekram1-node fc49b16584 fix(install): use latest release channel (#49261)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-15 21:30:38 -05:00
usrnk1andLukeParkerDev 9b091ebd37 feat(desktop): cycle new session location (#49171)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-16 11:17:52 +10:00
Dax 12201ee507 fix(cli): load web assets on demand
from OpenCode
2026-09-15 21:15:40 -04:00
Kit Langton 73bbeb7ce1 fix(tui): deduplicate plugin targets (#49112) 2026-09-16 01:03:58 +00:00
Kit Langton 370b9d484a test(tui): update descendant form expectation (#49246) 2026-09-16 00:51:51 +00:00
Dax Raad 796c423df5 fix(tui): remove web search integration category 2026-09-15 20:40:05 -04:00
Dax Raad 978bc53eba docs: complete v2 HTTP API audit 2026-09-15 20:38:01 -04:00
Dax Raad f7444aba27 fix(release): anchor review to stable tag 2026-09-15 20:36:47 -04:00
Dax Raad c987590c5f refactor(protocol): mark stateless generation experimental 2026-09-15 20:33:46 -04:00
Kit Langton 1a3bd3458c test(cli): align ACP fixture contracts (#49251) 2026-09-16 00:33:18 +00:00
Dax Raad 67a6d52015 chore(cli): reduce compiled runtime memory 2026-09-15 20:29:56 -04:00
Dax Raad b1f6d21ef3 refactor(protocol): refine vcs and shell APIs 2026-09-15 20:19:08 -04:00
Aiden Cline fa3721fad6 fix(codemode): treat tools.search as the built-in search (#49249) 2026-09-15 19:08:51 -05:00
Aiden Cline 02b12aa60e test(core): align plugin host fixture with session update and permission domains (#49243) 2026-09-15 18:43:52 -05:00
Dax Raad 1b6a07dda3 refactor(session): simplify permissions event 2026-09-15 19:24:50 -04:00
Dax Raad 547d60183d refactor(session): consolidate mutable properties 2026-09-15 18:50:43 -04:00
Dax Raad 12e53ee6d4 refactor(protocol): simplify interactive resources 2026-09-15 18:23:47 -04:00
David Hill b629b458f7 fix(app): match session title placeholder to tab label (#48735) 2026-09-16 08:20:08 +10:00
David Hill 24dff6929d feat(app): preview timeline images in attachment overlay (#49111) 2026-09-16 08:19:41 +10:00
Kit Langton 7e2ca5fe26 fix(tui): retry installed plugin dependencies (#49187) 2026-09-15 21:40:58 +00:00
Dax aaed6eaa45 fix(tui): clarify collapsed shell output (#49232) 2026-09-15 17:00:57 -04:00
Aiden Cline 01443d5e00 fix(core): preserve job failure details (#49226) 2026-09-15 15:30:53 -05:00
opencode-agent[bot]andrekram1-node 70fe186df4 test(app): wait for summary readiness (#49217)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-15 15:26:28 -05:00
opencode-agent[bot] fc731d6013 chore: update nix node_modules hashes 2026-09-15 20:10:17 +00:00
Aiden Cline 4cd6093e31 fix(codemode): keep one RegExp lastIndex (#49220) 2026-09-15 15:01:08 -05:00
Aiden Cline b693c222da feat(core): configure MCP OAuth authorization server metadata (#49126) 2026-09-15 14:52:30 -05:00
Aiden Cline e31ad14c1d fix(core): surface authorization cause in error message (#49214) 2026-09-15 14:38:15 -05:00
James Long 6c9115eb75 docs(www): restore regular body font weight (#49219) 2026-09-15 15:18:40 -04:00
Aiden Cline d9dfc5c1d6 refactor(codemode): extensions are host functions only (#49196) 2026-09-15 13:42:51 -05:00
opencode-agent[bot]andrekram1-node c921067eb8 fix(client): regenerate model compatibility type (#49204)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-15 13:31:18 -05:00
James Long b76e56eca3 docs(www): adjust text and background colors (#49202) 2026-09-15 13:34:54 -04:00
James Long 3c3ef00b32 docs(www): use variable IBM Plex Mono and refine body text (#49200) 2026-09-15 13:28:13 -04:00
Aiden Cline d54fc305db feat(core): append model identity to system prompts via plugin (#49129) 2026-09-15 11:46:07 -05:00
James Long a4aaf18855 docs(www): refine typography and background (#49191) 2026-09-15 12:43:18 -04:00
Aiden Cline d0a902815d feat(plugin): add experimental WebSocket handshake hook (#49131) 2026-09-15 01:41:00 -05:00
Aiden Cline 27aaa9ce0e feat(ai): allowlist prompt_cache_key sending per provider (#49121) 2026-09-15 01:23:49 -05:00
Aiden Cline 0b15abbf9a refactor(codemode): make the host boundary JSON.stringify plus a short table (#49128) 2026-09-15 01:23:20 -05:00
Aiden Cline 0f22b86670 refactor(core): replace websocket flags with a single transport preference (#48423) 2026-09-15 00:55:43 -05:00
630 changed files with 14244 additions and 10132 deletions
+85 -85
View File
@@ -29,15 +29,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
## Progress
- [x] Group 1: Foundation and placement (4)
- [ ] Group 2: Configuration and capability catalogs (16)
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
- [ ] Group 4: Session lifecycle (12)
- [ ] Group 5: Session execution and inputs (11)
- [ ] Group 6: Session history and recovery (13)
- [ ] Group 7: Inbox, permissions, and forms (19)
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
- [ ] Group 10: Events, RPC, and experimental operations (6)
- [x] Group 2: Configuration and capability catalogs (16)
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
- [x] Group 4: Session lifecycle (12)
- [x] Group 5: Session execution and inputs (11)
- [x] Group 6: Session history and recovery (13)
- [x] Group 7: Inbox, permissions, and forms (19)
- [x] Group 8: Filesystem, worktrees, and VCS (12)
- [x] Group 9: PTYs, persistent terminals, and shells (24)
- [x] Group 10: Events, RPC, and experimental operations (6)
## Resolved during audit
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
@@ -100,7 +100,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
## Group 3: Credentials, integrations, MCP, and web search
@@ -111,21 +111,21 @@ Review endpoints in document order. For each endpoint, select one disposition an
|---|---|---|---|---|---|
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
| [x] | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
@@ -147,7 +147,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.rename` | Change | Title-only rename now uses the session resource path. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
@@ -166,7 +166,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside the stable API. |
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
## Group 6: Session history and recovery
@@ -185,7 +185,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
@@ -196,24 +196,24 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
## Group 8: Filesystem, worktrees, and VCS
@@ -221,18 +221,18 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
## Group 9: PTYs, persistent terminals, and shells
@@ -240,30 +240,30 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
## Group 10: Events, RPC, and experimental operations
@@ -271,9 +271,9 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
| [x] | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
+36 -36
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -93,7 +93,6 @@
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -114,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.7",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -178,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -204,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -254,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -281,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -298,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -322,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -342,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -354,6 +353,7 @@
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.7",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -168,7 +168,7 @@ else
package_scope="@opencode"
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.3",
"version": "2.0.7",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.7",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -168,6 +168,10 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
}
})
: undefined
if (input.webSocket && !channel)
yield* Effect.logWarning(`${options.name} does not offer WebSocket for this endpoint; using HTTP`, {
url: parts.url,
})
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+10 -2
View File
@@ -336,12 +336,18 @@ export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
? { ...value, type: "error" }
: value,
),
+4 -1
View File
@@ -715,7 +715,10 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.promptCacheKey(request)
// Default off: strict providers 400 on unknown body fields, so only send
// the key where compatibility explicitly allows it. Header-based affinity
// (x-session-affinity, x-grok-conv-id, ...) is unaffected.
const cacheKey = (request.model.compatibility?.supportsPromptCacheKey ?? false) ? ProviderShared.promptCacheKey(request) : undefined
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
+21 -6
View File
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
)
return new ProviderInternalError({
...details,
+1 -7
View File
@@ -1,4 +1,3 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
@@ -61,11 +60,6 @@ const responsesRoute = OpenAIResponses.route.with({
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
@@ -135,7 +129,7 @@ export const configure = (input: Config) => {
const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID)
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } })
return {
id,
+1 -1
View File
@@ -44,7 +44,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false, supportsPromptCacheKey: true },
}),
configure,
}
+1 -1
View File
@@ -47,7 +47,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false, supportsPromptCacheKey: true },
}),
configure,
}
+2 -1
View File
@@ -41,7 +41,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
})
return {
id,
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
configure,
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
const chat = (modelID: string | ModelID) =>
configuredChat.model<OpenResponsesProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false },
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false, supportsPromptCacheKey: true },
})
const messages = (modelID: string | ModelID) =>
configuredMessages.model<MessagesOptionsInput>({
+1
View File
@@ -112,6 +112,7 @@ export const configure = (input: Config = {}) => {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
supportsPromptCacheKey: true,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
+4 -1
View File
@@ -91,7 +91,10 @@ export const configure = (input: Config = {}) => {
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
id,
compatibility: { supportsPromptCacheKey: true },
})
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
+2 -1
View File
@@ -183,7 +183,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) =>
route.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
configure,
}
}
+3
View File
@@ -168,6 +168,9 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
// key unless this is set; session-affinity headers still flow regardless.
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
it.effect("does not let server codes override a 4xx rejection", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
}),
)
+47 -3
View File
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -1,8 +1,9 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
+21 -4
View File
@@ -248,7 +248,23 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the request prompt cache key", () =>
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.compatible.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "compatible-model", compatibility: { supportsPromptCacheKey: true } }),
prompt: "Hello",
promptCacheKey: "session_123",
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
it.effect("omits the prompt cache key without the compatibility flag", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -261,7 +277,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
@@ -280,7 +296,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () =>
LLMClient.generate(
LLM.request({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
@@ -294,7 +310,8 @@ describe("OpenAI Chat route", () => {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
const body = decodeJson(yield* Effect.promise(() => web.text()))
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
// Chat uses the header; prompt_cache_key is Responses-only.
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBeUndefined()
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Logger, Ref, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -760,7 +760,11 @@ describe("OpenAI Responses route", () => {
)
const next = continuationDriver({
...request,
input: [...firstInput, reasoning, { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }],
input: [
...firstInput,
reasoning,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
})
const continued = yield* next.create(saved)
@@ -795,7 +799,10 @@ describe("OpenAI Responses route", () => {
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const appended = [...request.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }]
const appended = [
...request.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
]
const changes = [
{ ...request, model: "gpt-5.3", input: appended },
{ ...request, instructions: "Changed", input: appended },
@@ -804,7 +811,10 @@ describe("OpenAI Responses route", () => {
{ ...request, metadata: { source: "two" }, input: appended },
{
...request,
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] },
appended[1],
],
},
]
@@ -900,7 +910,10 @@ describe("OpenAI Responses route", () => {
const second = continuationDriver(
{
...firstRequest,
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
input: [
...firstRequest.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
],
},
classifyingChannelDriver,
)
@@ -951,7 +964,10 @@ describe("OpenAI Responses route", () => {
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
input: [
...firstRequest.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
@@ -1082,7 +1098,9 @@ describe("OpenAI Responses route", () => {
)
const expected = {
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] },
],
metadata: { source: "overlay\uFFFD" },
}
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
@@ -1124,7 +1142,9 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
// Azure's WebSocket upgrade accepts the same credential header as HTTP: `api-key` for keys,
// `Authorization: Bearer` for Entra tokens. Rewriting a key into a bearer is rejected.
it.effect("builds Azure WebSocket requests with v1 URLs and the route's auth header", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
@@ -1133,13 +1153,13 @@ describe("OpenAI Responses route", () => {
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
headers: { "api-key": "azure-key", authorization: undefined },
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
headers: { "api-key": undefined, authorization: "Bearer entra-token" },
},
]
@@ -1150,8 +1170,8 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers.authorization).toBe(item.headers.authorization)
expect(exchange.connect.headers["api-key"]).toBe(item.headers["api-key"])
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
@@ -1199,6 +1219,11 @@ describe("OpenAI Responses route", () => {
},
]
const warnings: string[] = []
const logger = Logger.make((entry) => {
if (entry.logLevel !== "Warn") return
warnings.push(String(Array.isArray(entry.message) ? entry.message[0] : entry.message))
})
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
@@ -1214,6 +1239,9 @@ describe("OpenAI Responses route", () => {
),
),
),
).pipe(Effect.provide(Logger.layer([logger])))
expect(warnings).toEqual(
cases.map(() => "Azure OpenAI Responses does not offer WebSocket for this endpoint; using HTTP"),
)
}),
)
@@ -1842,7 +1870,11 @@ describe("OpenAI Responses route", () => {
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }],
},
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
@@ -4599,8 +4631,20 @@ describe("OpenAI Responses route", () => {
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
)
expect(prepared.map((request) => request.body.input)).toEqual([
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
},
],
[
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
},
],
])
}),
)
@@ -42,3 +42,18 @@ story("hides and restores the same registration for Review tabs and unmount", as
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("hides the native view immediately while the pane stays mounted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const toggle = root.getByRole("button", { name: "Toggle Review tab", exact: true })
await expect(toggle).toBeEnabled()
// Read in the same task as the click so a deferred animation-frame hide cannot pass.
const visible = await toggle.evaluate((element) => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }))
return document.querySelector('[data-testid="native-Alpha"]')?.getAttribute("data-visible")
})
expect(visible).toBe("false")
await expect(root.locator("#browser-panel")).toHaveCount(1)
await toggle.click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
@@ -9,12 +9,17 @@ for (const theme of ["light", "dark"]) {
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
const shimmer = logo.locator(".wordmark-shimmer")
const base = logo.locator("svg").first()
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(base).toHaveCSS("opacity", "0.16")
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
await expect(logo.locator("g[mask]")).toHaveCount(0)
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
await expect(base.locator("path").nth(1)).toHaveAttribute("fill", "var(--icon-base)")
await expect(base.locator("path").nth(9)).toHaveCSS(
"fill",
await base.locator("path").nth(1).evaluate((element) => getComputedStyle(element).fill),
)
await expect(base.locator('path[fill="var(--icon-weak-base)"]')).toHaveCount(8)
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => {
@@ -77,7 +82,7 @@ for (const theme of ["light", "dark"]) {
await page.setViewportSize({ width: 360, height: 640 })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
const logo = component.locator('[data-component="new-session-wordmark"]')
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(logo.locator("svg").first()).toHaveCSS("opacity", "0.16")
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
@@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
started: message.time.created,
})
}
@@ -62,7 +62,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
@@ -108,6 +108,15 @@ test("non-Git folders show their status without offering worktree actions", asyn
).toBeEnabled()
})
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
const mock = await openDraft(page, "create", { currentDirectory: workspace })
await page.getByRole("button", { name: "New worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
await page.locator('[data-component="composer-editor"]').fill("Run locally")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.calls.find((call) => call.type === "session")?.directory).toBe(directory)
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
@@ -296,7 +305,12 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
async function openDraft(
page: Page,
worktree = "main",
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
) {
const currentDirectory = options.currentDirectory ?? directory
const project = {
id: "proj_new_summary",
worktree: directory,
@@ -315,7 +329,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
directory: currentDirectory,
project,
sessions,
provider: {
@@ -442,7 +456,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
@@ -455,12 +469,12 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
@@ -22,7 +22,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branches?*", (route) => {
await page.route("**/api/vcs/branch?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
@@ -1,54 +0,0 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { decision: "once" },
},
{
origin: serverA,
directory: undefined,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { reply: "once" },
body: { decision: "once" },
},
])
})
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
})
@@ -16,8 +16,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
}
return json(route, {})
})
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
row.delivery = input.action
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: "steer",
delivery: input.action,
})
},
}
@@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(thinking).toHaveCount(0)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
@@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) {
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
@@ -48,7 +48,10 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
if (
request.method() === "DELETE" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
)
rejectRequests.push(request.url())
})
@@ -107,7 +110,7 @@ test("shows a pending permission dock", async ({ page }) => {
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
expect(request.postDataJSON()).toEqual({ decision: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -138,7 +138,14 @@ test("MCP authentication starts before a slow resource catalog finishes", async
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
route.fulfill({
json: {
version: "2.0.0",
pid: 1,
urls: ["http://secondary.test"],
paths: { tmp: "/tmp/opencode" },
},
}),
)
await page.addInitScript(
({ directory, server }) => {
@@ -23,6 +23,7 @@ for (const custom of [false, true]) {
await page.goto(stressSessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(trigger).toBeEnabled()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/status")
const response = await fetch("/api/info")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
@@ -278,7 +278,11 @@ async function installMotionProbe(page: Page) {
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
if (
panelGap &&
reviewRegion.getBoundingClientRect().height > 1 &&
terminalRegion.getBoundingClientRect().height > 1
)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/status")
if (path === "/api/info")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -334,8 +334,13 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+6 -5
View File
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
@@ -112,10 +112,10 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
@@ -173,7 +173,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
HttpApiEndpoint.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
@@ -218,8 +218,9 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
payload: Schema.Struct({ delivery: Schema.Literals(["steer", "queue"]) }),
success: NoContent,
}),
)
+14 -4
View File
@@ -42,7 +42,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
}
type MockStreamWindow = Window & {
@@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
@@ -454,9 +460,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
sessionInboxUpdate: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
config.onInboxChange?.({
sessionID: ctx.params.sessionID,
inboxID: ctx.params.inboxID,
action: ctx.payload.delivery,
}),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.7",
"description": "",
"type": "module",
"exports": {
@@ -93,7 +93,6 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3"
"tailwindcss": "4.3.3"
}
}
+16 -24
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,29 +107,21 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
return (
<ServersProvider
@@ -16,6 +16,86 @@
}
}
[data-component="upload-row"] {
display: flex;
flex-direction: column;
gap: 4px;
& + & {
margin-top: 8px;
}
[data-slot="upload-row-label"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
}
[data-slot="upload-row-name"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--v2-text-text-base);
font-weight: 530;
}
[data-slot="upload-row-percent"] {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-variant-numeric: tabular-nums;
}
[data-slot="upload-row-track"] {
display: flex;
align-items: center;
gap: 8px;
}
[data-component="upload-progress"] {
flex: 1;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--v2-background-bg-layer-02);
}
[data-slot="upload-progress-bar"] {
height: 100%;
border-radius: inherit;
background: var(--v2-icon-icon-base);
transition: width 160ms ease-out;
}
[data-slot="upload-row-cancel"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
svg {
width: 12px;
height: 12px;
}
}
}
[data-component="composer-attachments"] {
timeline-scope: --composer-attachments-scroll;
@@ -1,66 +1,12 @@
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createBlobReference } from "@/runtime/persistence/drafts"
import { uuid } from "@/runtime/persistence/uuid"
import type { ComposerAttachment, ComposerPrompt } from "../types"
const accepted = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"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",
]
import type { ComposerPrompt } from "../types"
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
import type { AttachmentDestination } from "./destination"
import { uploads } from "./uploads"
type PromptTarget = {
current: () => ComposerPrompt
@@ -74,10 +20,11 @@ export type ComposerAttachmentConfig = {
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
directory: () => string
destination: () => AttachmentDestination
isDialogActive: () => boolean
warn: () => void
duplicate: () => void
onError: (error: unknown) => void
onUploadError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
@@ -102,13 +49,23 @@ export function createComposerAttachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
// A file the model reads natively travels inline with the prompt, so its bytes live in the draft
// store. Anything else reaches the model as a path on the server and never enters the store:
// hashing and copying a large archive through it is what used to freeze the window.
const add = async (file: File, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
if (toast) input.warn()
return false
}
const destination = input.destination()
if (native(mime, destination.input)) return addInline(file, mime, target, clipboard)
const sourcePath = input.getPathForFile?.(file) || undefined
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
void stage(file, mime, target, destination)
return true
}
const addInline = async (file: File, mime: string, target: NonNullable<ReturnType<typeof capture>>, clipboard: boolean) => {
const blob = input.store ? await input.store(file) : await createBlobReference(file)
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
@@ -127,24 +84,45 @@ export function createComposerAttachments(
input.duplicate()
return true
}
const attachment: ComposerAttachment = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath,
mime,
blob,
}
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachments = async (files: File[], toast = true, target = capture()) => {
const found = await files.reduce(async (result, file) => {
const addPath = (
target: NonNullable<ReturnType<typeof capture>>,
attachment: Pick<PathAttachmentPart, "filename" | "mime" | "path">,
) => {
if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) {
input.duplicate()
return true
}
target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor())
return true
}
const stage = async (
file: File,
mime: string,
target: NonNullable<ReturnType<typeof capture>>,
destination: AttachmentDestination,
) => {
const id = uuid()
setPending("ids", (ids) => [...ids, id])
const path = await uploads
.track({ id, filename: file.name, mime, size: file.size }, (report, signal) =>
destination.upload(file, report, signal),
)
.catch((error: unknown) => {
input.onUploadError(error)
return undefined
})
.finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id)))
if (path) addPath(target, { filename: file.name, mime, path })
}
const addAttachments = async (files: File[], target = capture()) => {
return files.reduce(async (result, file) => {
const previous = await result
return (await add(file, false, target)) || previous
return (await add(file, target)) || previous
}, Promise.resolve(false))
if (!found && files.length > 0 && toast) input.warn()
return found
}
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
@@ -159,13 +137,13 @@ export function createComposerAttachments(
return file ? [file] : []
})
if (files.length > 0) {
await addAttachments(files, true, target)
await addAttachments(files, target)
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file && (await add(file, true, target, true))) return
if (file && (await add(file, target, true))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
@@ -218,20 +196,31 @@ export function createComposerAttachments(
addAttachments,
handlePaste,
handleDrop,
/** Uploads still in flight for this composer; sending waits for them. */
pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)),
cancel(id: string) {
uploads.items().find((item) => item.id === id)?.cancel()
},
pick(fallback: () => void) {
if (!input.picker) {
fallback()
return
}
void input
.picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
.catch(input.onError)
void input.picker({ defaultPath: input.directory(), multiple: true }, (file) => add(file)).catch(input.onError)
},
}
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
const imageExtensions = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
@@ -249,6 +238,8 @@ const textMimes = new Set([
"application/yaml",
])
// Text-like files normalize to text/plain so the server inlines their content; every other
// file keeps a binary type and is delivered to the model by path or as native media.
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
@@ -259,10 +250,11 @@ async function attachmentMime(file: File) {
if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
return "text/plain"
}
const binary = type || "application/octet-stream"
const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer())
if (bytes.some((byte) => byte === 0)) return
if (bytes.some((byte) => byte === 0)) return binary
const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
if (bytes.length > 0 && control / bytes.length > 0.3) return
if (bytes.length > 0 && control / bytes.length > 0.3) return binary
return "text/plain"
}
@@ -0,0 +1,57 @@
import type { Accessor } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { authTokenFromCredentials } from "@/runtime/server/api"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Streams a file into the server's temporary directory and returns its absolute path there. */
upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise<string>
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file, report, signal) => {
const info = await sdk.api.server.info({ signal })
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
url.searchParams.set("location[directory]", location().directory)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
return write(url, file, server.conn.http.password, report, signal)
},
})
}
// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so
// the one request that needs both goes through XMLHttpRequest. The browser streams the File
// from disk; nothing is buffered in the renderer.
function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) {
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", url)
xhr.responseType = "json"
xhr.setRequestHeader("content-type", "application/octet-stream")
if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`)
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
xhr.addEventListener("load", () => {
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
resolve((xhr.response as { data: { path: string } }).data.path)
})
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")))
signal.addEventListener("abort", () => xhr.abort(), { once: true })
xhr.send(file)
})
}
@@ -0,0 +1,116 @@
import { createEffect, createRoot, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { Toast, toaster } from "@opencode/ui/toast"
import { useLanguage } from "@/runtime/i18n/language"
export type Upload = {
id: string
filename: string
mime: string
size: number
loaded: number
cancel: () => void
}
// Uploads outlive the composer that started them, so one process-wide list feeds every chip
// and the single progress toast.
const [state, setState] = createStore<{ items: Upload[] }>({ items: [] })
export const uploads = {
items: () => state.items,
/** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */
async track<T>(
input: Pick<Upload, "id" | "filename" | "mime" | "size">,
work: (report: (loaded: number) => void, signal: AbortSignal) => Promise<T>,
): Promise<T | undefined> {
const controller = new AbortController()
setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }])
try {
return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal)
} catch (error) {
if (controller.signal.aborted) return undefined
throw error
} finally {
setState("items", (items) => items.filter((item) => item.id !== input.id))
}
},
}
// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host
// lives inside the providers, lends the toast its language instance, and gives the content a
// root of its own so progress stays reactive.
export function UploadToastHost() {
const language = useLanguage()
let active: { id: number; dispose: () => void } | undefined
const dismiss = () => {
if (!active) return
toaster.dismiss(active.id)
active.dispose()
active = undefined
}
createEffect(
on(
() => state.items.length > 0,
(uploading) => {
if (!uploading) return dismiss()
if (active) return
const id = toaster.show(
(props) =>
createRoot((dispose) => {
active = { id: props.toastId, dispose }
return <UploadToast toastId={props.toastId} language={language} />
}),
{ persistent: true, resize: () => state.items.length },
)
active ??= { id, dispose: () => {} }
},
),
)
onCleanup(dismiss)
return null
}
function UploadToast(props: { toastId: number; language: ReturnType<typeof useLanguage> }) {
const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100))
return (
<Toast toastId={props.toastId}>
<Toast.Content>
<For each={state.items}>
{(item) => (
<div data-component="upload-row">
<div data-slot="upload-row-label">
<span data-slot="upload-row-name" title={item.filename}>
{item.filename}
</span>
<span data-slot="upload-row-percent">
{props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })}
</span>
</div>
<div data-slot="upload-row-track">
<div
data-component="upload-progress"
role="progressbar"
aria-label={item.filename}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent(item)}
>
<div data-slot="upload-progress-bar" style={{ width: `${percent(item)}%` }} />
</div>
<button
type="button"
data-slot="upload-row-cancel"
aria-label={props.language.t("prompt.toast.uploading.cancel")}
onClick={() => item.cancel()}
>
<Icon name="outline-xmark" />
</button>
</div>
</div>
)}
</For>
</Toast.Content>
</Toast>
)
}
+20
View File
@@ -8,6 +8,13 @@ export type PromptComment = {
origin?: "review" | "file"
}
/** An attachment the model receives as a path on the server rather than inline bytes. */
export type PromptAttachmentReference = {
name: string
mime: string
path: string
}
function selection(selection: unknown) {
if (!selection || typeof selection !== "object") return undefined
const startLine = Number((selection as FileSelection).startLine)
@@ -58,8 +65,17 @@ export function readPromptPresentation(value: unknown) {
const displayText = (value as { displayText?: unknown }).displayText
const comments = (value as { comments?: unknown }).comments
if (typeof displayText !== "string" || !Array.isArray(comments)) return
const attachments = (value as { attachments?: unknown }).attachments
return {
displayText,
attachments: (Array.isArray(attachments) ? attachments : []).flatMap((item): PromptAttachmentReference[] => {
if (!item || typeof item !== "object") return []
const name = (item as { name?: unknown }).name
const mime = (item as { mime?: unknown }).mime
const path = (item as { path?: unknown }).path
if (typeof name !== "string" || typeof mime !== "string" || typeof path !== "string") return []
return [{ name, mime, path }]
}),
comments: comments.flatMap((item): PromptComment[] => {
if (!item || typeof item !== "object") return []
const path = (item as { path?: unknown }).path
@@ -80,6 +96,10 @@ export function readPromptPresentation(value: unknown) {
}
}
export function formatAttachmentReference(input: PromptAttachmentReference) {
return `Attached file: \`${input.path}\``
}
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
+6 -6
View File
@@ -7,7 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { promptLength } from "../prompt-parts"
import { isAttachment, promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()((state) => ({
prompt: [
{ type: "text", content, start: 0, end: content.length },
...state.prompt.filter((part) => part.type === "image"),
...state.prompt.filter(isAttachment),
],
cursor: content.length,
retry: undefined,
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
clearRetry()
},
removeAttachment(id: string) {
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
clearRetry()
},
}
@@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co
let position = 0
let inserted = false
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
if (isAttachment(part)) return [part]
const start = position
position += part.content.length
if (inserted) return [part]
@@ -121,7 +121,7 @@ function insertMention(
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
if (isAttachment(part)) return [part]
const partStart = position
position += part.content.length
if (part.type !== "text" || start < partStart || end > position) return [part]
@@ -139,7 +139,7 @@ function insertMention(
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
let offset = 0
return prompt.map((part) => {
if (part.type === "image") return part
if (isAttachment(part)) return part
const next = { ...part, start: offset, end: offset + part.content.length }
offset = next.end
return next
+47 -8
View File
@@ -12,6 +12,8 @@ import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { ScrollView } from "@opencode/ui/scroll-view"
import { AttachmentCard } from "@opencode/session-ui/attachment-card"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
@@ -24,6 +26,7 @@ import type {
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
@@ -102,7 +105,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
ref={props.controller.setFileInput}
type="file"
multiple
accept="image/png,image/jpeg,image/gif,image/webp,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"
class="hidden"
onChange={(event) => {
const list = event.currentTarget.files
@@ -149,11 +151,13 @@ export function ComposerEditor(props: ComposerEditorProps) {
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
/>
@@ -192,9 +196,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
const attachments = props.controller.parts().filter(isAttachment)
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
@@ -349,7 +353,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (part.type === "image") return []
if (isAttachment(part)) return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
@@ -476,17 +480,22 @@ function composerCursor(editor: HTMLDivElement) {
export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
comments?: ComposerComment[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
onAttachmentRemove: (attachment: ComposerAttachment) => void
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<Show
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -523,22 +532,30 @@ export function ComposerAttachments(props: {
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Show
when={attachment.mime.startsWith("image/")}
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
{(image) => (
<>
<img
src={attachment.blob.url}
src={image().blob.url}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)}
</Show>
</Tooltip>
<button
@@ -552,6 +569,28 @@ export function ComposerAttachments(props: {
</div>
)}
</For>
<For each={props.uploads ?? []}>
{(upload) => (
<div class="relative group shrink-0" data-slot="composer-upload">
<Tooltip value={upload.filename} placement="top" contentClass="break-all">
<AttachmentCard title={upload.filename}>
<span class="inline-flex items-center gap-1">
<ProgressCircle percentage={percent(upload)} />
{i18n.t("ui.promptInput.uploading", { percent: percent(upload) })}
</span>
</AttachmentCard>
</Tooltip>
<button
type="button"
onClick={() => props.onUploadCancel?.(upload)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={i18n.t("ui.promptInput.cancelUpload")}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</div>
<div
data-slot="composer-attachments-fade-left"
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { shouldHandlePasteAsAttachment } from "./interaction"
test("leaves paste to the browser when web clipboard data is unavailable", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
})
test("uses native image reading only when the clipboard has no text", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
})
test("handles clipboard files as attachments", () => {
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
})
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
return { types, items } as unknown as DataTransfer
}
+29 -14
View File
@@ -2,6 +2,7 @@ import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
import type { Upload } from "../attachments/uploads"
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
import type {
ComposerAttachment,
@@ -18,7 +19,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
@@ -74,7 +75,7 @@ export function createComposerEditor(input: {
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (part.type === "image") return false
if (isAttachment(part)) return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
@@ -169,7 +170,7 @@ export function createComposerEditor(input: {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
draft.state.prompt.filter(isAttachment),
0,
)
}
@@ -315,7 +316,13 @@ export function createComposerEditor(input: {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
return draft.state.prompt.filter(isAttachment)
},
uploads(): Upload[] {
return attachments?.pending() ?? []
},
cancelUpload(id: string) {
attachments?.cancel(id)
},
toggleContext(id: string) {
dispatch({ type: "context.active", id })
@@ -336,11 +343,12 @@ export function createComposerEditor(input: {
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
if (attachments?.pending().length) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some((part) => part.type === "image")) return true
if (persisted.prompt.some(isAttachment)) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
@@ -369,6 +377,7 @@ export function createComposerEditor(input: {
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
if (attachments?.pending().length) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
@@ -384,20 +393,20 @@ export function createComposerEditor(input: {
},
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
if (
attachments &&
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
) {
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
void attachments.handlePaste(event)
return
}
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
if (
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
@@ -408,13 +417,13 @@ export function createComposerEditor(input: {
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(text)
const node = document.createTextNode(normalized)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
@@ -450,6 +459,12 @@ export function createComposerEditor(input: {
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
import { clonePrompt, isAttachment } from "../prompt-parts"
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
@@ -35,9 +35,9 @@ export function prependHistoryEntry(
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
const hasAttachments = prompt.some(isAttachment)
const hasComments = comments.some((comment) => !!comment.comment.trim())
if (!text && !hasImages && !hasComments) return entries
if (!text && !hasAttachments && !hasComments) return entries
const entry = {
prompt: clonePrompt(prompt),
@@ -86,7 +86,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt
if (partA.type === "skill") {
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
}
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
}
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
+14 -9
View File
@@ -17,11 +17,12 @@ import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode/schema/skill"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import { isAttachment } from "./prompt-parts"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/destination"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -72,7 +73,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}, [])
})
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
prompt.current().filter(isAttachment),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
@@ -319,8 +320,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
openAttachment: (attachment) => {
if (attachment.type !== "image") return
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
},
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, adapter.controls(), layout, files, comments)
@@ -338,13 +341,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
destination: useAttachmentDestination(adapter.controls),
isDialogActive: () => !!dialog.active,
warn: () =>
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onUploadError: (error) =>
showToast({
variant: "error",
title: language.t("prompt.toast.uploadFailed.title"),
description: composerErrorMessage(language, error),
}),
onError: (error) =>
showToast({
variant: "error",
+7 -2
View File
@@ -1,4 +1,9 @@
import type { Prompt } from "./state"
import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state"
/** Parts that sit beside the text rather than inside it. */
export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart {
return part.type === "image" || part.type === "path"
}
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
@@ -17,7 +22,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}
+9 -21
View File
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Skill } from "@opencode/schema/skill"
import type { Prompt } from "@/composer/state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { buildPromptRequest } from "./request"
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>) {
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
}
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
const prompt: Prompt = [
@@ -21,9 +25,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
images: [inline("a.png", "image/png")],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -45,16 +47,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
text: "check these",
sessionDirectory: "/repo",
})
@@ -70,14 +63,9 @@ describe("buildPromptRequest", () => {
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
inline("opencode.global.dat", "text/plain", {
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
}),
],
text: "inspect this",
sessionDirectory: "C:\\Repos\\sst\\opencode",
+21 -5
View File
@@ -1,8 +1,13 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import { formatCommentNote, type PromptComment } from "@/composer/comment-note"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import {
formatAttachmentReference,
formatCommentNote,
type PromptAttachmentReference,
type PromptComment,
} from "@/composer/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
@@ -12,6 +17,7 @@ type PromptRequest = {
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
skills: { id: string; name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
attachments: PromptAttachmentReference[]
}
type ContextFile = {
@@ -56,6 +62,7 @@ const parseCommentMentions = (comment: string) => {
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
@@ -106,18 +113,27 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
return [file, ...mentions]
})
const images = input.images.map((attachment) => ({
const inline = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.prompt
.filter(isPathAttachment)
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
return {
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
text: [
...(input.text.trim() ? [input.text] : []),
...attachments.map(formatAttachmentReference),
...comments.map(formatCommentNote),
].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
files: [...files, ...context, ...inline],
agents,
skills,
comments,
attachments,
}
}
+18 -1
View File
@@ -94,7 +94,24 @@ export const ImageAttachmentPart = Schema.Struct({
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
// A file the model receives as a path on the server: its bytes never enter the draft store.
export const PathAttachmentPart = Persistence.struct({
type: Schema.Literal("path"),
id: Schema.String,
filename: Schema.String,
mime: Schema.String,
path: Schema.String,
})
export type PathAttachmentPart = typeof PathAttachmentPart.Type
export const ContentPart = Schema.Union([
TextPart,
FileAttachmentPart,
AgentPart,
SkillPart,
ImageAttachmentPart,
PathAttachmentPart,
])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
+1
View File
@@ -23,6 +23,7 @@ export type {
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
PromptModel,
SkillPart,
@@ -1,5 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt } from "./prompt-parts"
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@@ -22,7 +22,7 @@ export function createComposerSubmission(input: {
if (initial !== target) {
initial.reset()
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
+8 -7
View File
@@ -9,6 +9,7 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -151,6 +152,9 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
})),
metadata: {
displayText: value.text,
attachments: value.prompt.flatMap((part) =>
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
),
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
@@ -185,7 +189,7 @@ function readSubmission(
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && images.length === 0 && comments === 0) return
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
@@ -358,6 +362,7 @@ async function sendPrompt(
metadata: {
displayText: request.displayText,
comments: request.comments,
attachments: request.attachments,
agent: value.selection.agent,
model: {
...value.selection.model,
@@ -372,19 +377,15 @@ async function sendPrompt(
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
)
const request = buildPromptRequest({
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
images,
text: value.text,
sessionDirectory: session.directory,
})
return request
}
function failSubmission(
@@ -1,4 +1,5 @@
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
import { isAttachment } from "../prompt-parts"
export type ComposerInteractionState = {
mode: "normal" | "shell"
@@ -236,7 +237,7 @@ function populated(persisted: ComposerPersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
)
}
+10 -2
View File
@@ -1,9 +1,17 @@
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
import type {
AgentPart,
ComposerStore,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "./state"
export type ComposerFilePart = FileAttachmentPart
export type ComposerAgentPart = AgentPart
export type ComposerSkillPart = SkillPart
export type ComposerAttachment = ImageAttachmentPart
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
export type ComposerPrompt = Prompt
export type ComposerComment = ComposerStore["context"]["items"][number]
export type ComposerPersistedState = ComposerStore
+1 -1
View File
@@ -1,5 +1,4 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
@@ -15,6 +14,7 @@ export type {
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
@@ -146,7 +146,7 @@ export function createHomeSessionsController(home: HomeController) {
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.update({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
+21 -2
View File
@@ -42,8 +42,14 @@
container-name: session-chat;
}
[data-component="new-session"] {
container-type: inline-size;
container-name: new-session;
}
[data-slot="session-chat-panel"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div),
[data-slot="new-session-content"] {
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -53,7 +59,8 @@
}
[data-slot="session-chat-panel"][data-summary-resizing="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div),
[data-component="new-session"][data-summary-resizing="true"] [data-slot="new-session-content"] {
transition: none;
}
@@ -70,6 +77,18 @@
}
}
/* Shift only while centered content overlaps the 280px summary cards and their 16px edge inset:
880px + 2 * (280px + 16px) = 1472px. Center it in the remaining space, reserving 320px. */
@container new-session (min-width: 1200px) and (width < 1472px) {
[data-component="new-session"][data-summary-open="true"] [data-slot="new-session-content"] {
--session-summary-translate: -160px;
&:dir(rtl) {
--session-summary-translate: 160px;
}
}
}
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
[data-slot="session-timeline-scroll"]
> .scroll-view__thumb {
+12
View File
@@ -9,6 +9,10 @@ export function useNewSessionCommands(input: {
empty: () => boolean
open: () => void
}
workspace: {
enabled: () => boolean
cycle: () => void
}
}) {
const command = useCommand()
const dialog = useDialog()
@@ -40,5 +44,13 @@ export function useNewSessionCommands(input: {
disabled: input.project.empty(),
onSelect: input.project.open,
},
{
id: "session.location.cycle",
title: language.t("command.session.location.cycle"),
category: language.t("command.category.workspace"),
keybind: "mod+alt+l",
disabled: !input.workspace.enabled(),
onSelect: input.workspace.cycle,
},
])
}
@@ -48,7 +48,8 @@ export function createNewSessionComposerAdapter(props: {
submitted: props.submitted,
async start(selection, submission, message) {
const draftID = props.draftID
const projectDirectory = location().directory
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
+4
View File
@@ -60,6 +60,10 @@ export default function NewSessionPage(props: { draftId: string }) {
empty: project.empty,
open: () => project.setOpen(true),
},
workspace: {
enabled: workspace.bar.visible,
cycle: workspace.selection.cycle,
},
})
createEffect(() => {
if (!composer.ready()) return
+26 -3
View File
@@ -1,8 +1,10 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { debounce } from "@solid-primitives/scheduled"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
@@ -48,7 +50,21 @@ export function NewSessionView(props: {
workspace: NewSessionWorkspaceController
mcp: DraftMcpControls
}) {
const [store, setStore] = createStore({ summary: false })
const [store, setStore] = createStore({
summary: false,
content: undefined as HTMLDivElement | undefined,
summaryResizeTranslate: undefined as string | undefined,
})
const finishWindowResize = debounce(() => setStore("summaryResizeTranslate", undefined), 150)
onMount(() => {
makeEventListener(window, "resize", () => {
if (store.summaryResizeTranslate === undefined) {
// Freeze the painted offset, including an in-flight slide, until resizing settles.
setStore("summaryResizeTranslate", store.content ? getComputedStyle(store.content).translate : "none")
}
finishWindowResize()
})
})
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
@@ -63,6 +79,9 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
data-summary-open={store.summary}
data-summary-resizing={store.summaryResizeTranslate !== undefined}
style={{ "--session-summary-resize-translate": store.summaryResizeTranslate }}
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<ComposerDropzone
@@ -89,7 +108,11 @@ export function NewSessionView(props: {
</SummaryPopover>
</div>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<div
ref={(element) => setStore("content", element)}
data-slot="new-session-content"
class={NEW_SESSION_CONTENT_WIDTH}
>
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
@@ -1,7 +1,12 @@
[data-component="new-session-wordmark"] {
--icon-strong-base: var(--icon-base);
--wordmark-shimmer-opacity: 0.45;
.wordmark-shimmer {
--icon-base: white;
--icon-strong-base: white;
--icon-weak-base: transparent;
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
color: white;
mix-blend-mode: screen;
@@ -13,6 +18,10 @@
}
}
[data-color-scheme="light"] [data-component="new-session-wordmark"] {
--icon-weak-base: color-mix(in srgb, var(--icon-base) 30%, transparent);
}
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
--wordmark-shimmer-opacity: 0.0672;
+4 -4
View File
@@ -1,4 +1,4 @@
import { Wordmark } from "@opencode/ui/wordmark"
import { Logo } from "@opencode/ui/logo"
import "./wordmark.css"
export function NewSessionWordmark() {
@@ -8,9 +8,9 @@ export function NewSessionWordmark() {
aria-hidden="true"
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
>
<div data-slot="wordmark-reveal" class="relative">
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
<div data-slot="wordmark-reveal" class="relative mx-auto w-4/5">
<Logo class="block aspect-[720/129] w-full opacity-[0.16]" />
<Logo class="wordmark-shimmer absolute inset-0 aspect-[720/129] w-full" />
</div>
</div>
)
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
import {
cycleNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -64,4 +69,16 @@ describe("new session workspace selection", () => {
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
expect(resolveNewSessionGit({})).toBe(false)
})
test("cycles between local and a new worktree", () => {
expect(cycleNewSessionWorktree({ current: "main" })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create" })).toBe("main")
})
test("includes the selected existing worktree in the cycle", () => {
const existing = "/project/feature"
expect(cycleNewSessionWorktree({ current: existing, existing })).toBe("main")
expect(cycleNewSessionWorktree({ current: "main", existing })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create", existing })).toBe(existing)
})
})
@@ -37,6 +37,12 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
return input.projectVcs === "git" || input.branch !== undefined
}
export function cycleNewSessionWorktree(input: { current: string; existing?: string }) {
if (input.current === "main") return "create"
if (input.current === "create") return input.existing ?? "main"
return "main"
}
export function createNewSessionWorkspaceController(input: {
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
@@ -49,7 +55,10 @@ export function createNewSessionWorkspaceController(input: {
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({ search: "" })
const [state, setState] = createStore({
search: "",
existing: undefined as { projectID: string; directory: string } | undefined,
})
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
@@ -142,7 +151,7 @@ export function createNewSessionWorkspaceController(input: {
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branches({ location: { directory }, search, limit: 50 })
.branch.list({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
@@ -159,6 +168,8 @@ export function createNewSessionWorkspaceController(input: {
createEffect(() => {
const selection = value()
if (selection === "main" || selection === "create") return
const project = currentProject()
if (project) setState("existing", { projectID: project.id, directory: selection })
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
})
const branch = createMemo(() =>
@@ -176,6 +187,20 @@ export function createNewSessionWorkspaceController(input: {
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
const select = (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
}
// The remembered worktree may have been removed since it was selected. Cycling to a directory the
// inventory no longer contains would resolve back to the fallback and leave the cycle stuck.
const existing = () => {
const project = currentProject()
const previous = state.existing
if (!project || previous?.projectID !== project.id) return
if (!worktreeDirectories().some((item) => sameDirectory(item, previous.directory))) return
return previous.directory
}
return {
selection: {
@@ -193,11 +218,8 @@ export function createNewSessionWorkspaceController(input: {
input.setSelectedBranch(undefined)
},
remember,
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
},
set: select,
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
-4
View File
@@ -191,7 +191,6 @@ export const dict = {
"dialog.provider.opencode.note": "Claude፣ GPT፣ Gemini እና ሌሎችንም ጨምሮ የተስተካከሉ ሞዴሎች",
"dialog.provider.opencode.tagline": "ታማኝ የተመቻቹ ሞዴሎች",
"dialog.provider.opencodeGo.tagline": "ዝቅተኛ ወጪ ለሁሉም ሰው",
"dialog.provider.anthropic.note": "የቀጥታ የClaude ሞዴሎች፣ ፕሮ እና ማክስን ጨምሮ",
"dialog.provider.copilot.note": "AI ሞዴሎች በGitHub Copilot] በኩል ኮድ ለማድረግ እገዛ",
"dialog.provider.openai.note": "GPT ሞዴሎች ለፈጣን እና ችሎታ ላለው አጠቃላይ AI ተግባራት",
"dialog.provider.google.note": "Gemini ለፈጣን፣ የተዋቀሩ ምላሾች ሞዴሎች",
@@ -208,7 +207,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "ከ70+ በላይ አቅራቢዎችን ይመልከቱ",
"dialog.provider.viewAll": "ተጨማሪ አቅራቢዎችን አሳይ",
"provider.connect.title": "{{provider}} አገናኝ",
"provider.connect.title.anthropicProMax": "በClaude Pro/Max ይግቡ",
"provider.connect.selectMethod": "የመግቢያ ዘዴን ለ{{provider}} ምረጥ።",
"provider.connect.method.apiKey": "API ቁልፍ",
"provider.connect.method.browser": "አሳሽ",
@@ -371,8 +369,6 @@ export const dict = {
"prompt.attachment.remove": "ዓባሪን አስወግድ",
"prompt.action.send": "ላክ",
"prompt.action.stop": "አቁም",
"prompt.toast.pasteUnsupported.title": "የማይደገፍ ዓባሪ",
"prompt.toast.pasteUnsupported.description": "ምስሎች፣ ፒዲኤፎች ወይም የጽሑፍ ፋይሎች ብቻ እዚህ ጋር ሊጣመሩ ይችላሉ።",
"prompt.toast.attachmentDuplicate.title": "ይህ ፋይል አስቀድሞ ተሰቅሏል",
"prompt.toast.modelAgentRequired.title": "ወኪል እና ሞዴል ይምረጡ",
"prompt.toast.modelAgentRequired.description": "ፕሮምፕት ከመላክዎ በፊት ወኪል እና ሞዴል ይምረጡ።",
-4
View File
@@ -198,7 +198,6 @@ export const dict = {
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
"dialog.provider.anthropic.note": "وصول مباشر إلى نماذج Claude، بما فيها Pro و Max",
"dialog.provider.copilot.note": "نماذج ذكاء اصطناعي للمساعدة في البرمجة عبر GitHub Copilot",
"dialog.provider.openai.note": "نماذج GPT لمهام الذكاء الاصطناعي العامة السريعة والمتقدمة",
"dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة",
@@ -215,7 +214,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "عرض أكثر من 70 موفرًا إضافيًا",
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
"provider.connect.title": "الاتصال بـ {{provider}}",
"provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max",
"provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.",
"provider.connect.method.apiKey": "مفتاح API",
"provider.connect.method.browser": "المتصفح",
@@ -380,9 +378,7 @@ export const dict = {
"prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال",
"prompt.action.stop": "إيقاف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
"prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",
-4
View File
@@ -194,7 +194,6 @@ export const dict = {
"dialog.provider.opencode.note": "Claude, GPT, Gemini və daha çoxu daxil olmaqla seçilmiş modellər",
"dialog.provider.opencode.tagline": "Etibarlı optimallaşdırılmış modellər",
"dialog.provider.opencodeGo.tagline": "Hamı üçün aşağı qiymətli abunəlik",
"dialog.provider.anthropic.note": "Pro və Max daxil olmaqla Claude modellərinə birbaşa giriş",
"dialog.provider.copilot.note": "GitHub Copilot vasitəsilə kodlaşdırma yardımı üçün AI modelləri",
"dialog.provider.openai.note": "Sürətli və bacarıqlı ümumi AI tapşırıqları üçün GPT modelləri",
"dialog.provider.google.note": "Sürətli, strukturlaşdırılmış cavablar üçün Gemini modelləri",
@@ -211,7 +210,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Daha 70+ provayderə baxın",
"dialog.provider.viewAll": "Daha çox provayder göstər",
"provider.connect.title": "{{provider}} provayderini qoş",
"provider.connect.title.anthropicProMax": "Claude Pro/Max ilə daxil ol",
"provider.connect.selectMethod": "{{provider}} üçün giriş üsulunu seçin.",
"provider.connect.method.apiKey": "API açarı",
"provider.connect.method.browser": "Brauzer",
@@ -378,8 +376,6 @@ export const dict = {
"prompt.attachment.remove": "Əlavəni sil",
"prompt.action.send": "Göndər",
"prompt.action.stop": "Dayandır",
"prompt.toast.pasteUnsupported.title": "Dəstəklənməyən əlavə",
"prompt.toast.pasteUnsupported.description": "Buraya yalnız şəkillər, PDF-lər və ya mətn faylları əlavə edilə bilər.",
"prompt.toast.attachmentDuplicate.title": "Bu fayl artıq yüklənib",
"prompt.toast.modelAgentRequired.title": "Agent və model seçin",
"prompt.toast.modelAgentRequired.description": "Prompt göndərməzdən əvvəl agent və model seçin.",
-4
View File
@@ -194,7 +194,6 @@ export const dict = {
"dialog.provider.opencode.note": "Подбрани модели, включително Claude, GPT, Gemini и други",
"dialog.provider.opencode.tagline": "Надеждни оптимизирани модели",
"dialog.provider.opencodeGo.tagline": "Абонамент на ниска цена за всеки",
"dialog.provider.anthropic.note": "Директен достъп до Claude модели, включително Pro и Max",
"dialog.provider.copilot.note": "AI модели за помощ при кодиране чрез GitHub Copilot",
"dialog.provider.openai.note": "GPT модели за бързи, способни общи задачи с изкуствен интелект",
"dialog.provider.google.note": "Gemini модели за бързи, структурирани отговори",
@@ -211,7 +210,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Вижте още 70+ доставчици",
"dialog.provider.viewAll": "Показване на още доставчици",
"provider.connect.title": "Свържете {{provider}}",
"provider.connect.title.anthropicProMax": "Влезте с Claude Pro/Max",
"provider.connect.selectMethod": "Изберете метод за влизане за {{provider}}.",
"provider.connect.method.apiKey": "API ключ",
"provider.connect.method.browser": "Браузър",
@@ -378,8 +376,6 @@ export const dict = {
"prompt.attachment.remove": "Премахване на прикачения файл",
"prompt.action.send": "Изпратете",
"prompt.action.stop": "Спрете",
"prompt.toast.pasteUnsupported.title": "Неподдържан прикачен файл",
"prompt.toast.pasteUnsupported.description": "Тук могат да се прикачват само изображения, PDF или текстови файлове.",
"prompt.toast.attachmentDuplicate.title": "Този файл вече е качен",
"prompt.toast.modelAgentRequired.title": "Изберете агент и модел",
"prompt.toast.modelAgentRequired.description": "Изберете агент и модел, преди да изпратите подкана.",
-4
View File
@@ -192,7 +192,6 @@ export const dict: Record<string, string> = {
"dialog.provider.opencode.note": "Claude, GPT, Gemini এবং আরও অনেক কিছু সহ কিউরেট করা মডেল",
"dialog.provider.opencode.tagline": "নির্ভরযোগ্য অপ্টিমাইজ করা মডেল",
"dialog.provider.opencodeGo.tagline": "সবার জন্য কম খরচে সাবস্ক্রিপশন",
"dialog.provider.anthropic.note": "প্রো এবং ম্যাক্স সহ Claude মডেলগুলিতে সরাসরি অ্যাক্সেস",
"dialog.provider.copilot.note": "GitHub Copilot এর মাধ্যমে কোডিং সহায়তার জন্য AI মডেল",
"dialog.provider.openai.note": "দ্রুত, সক্ষম সাধারণ AI কার্যগুলির জন্য GPT মডেল",
"dialog.provider.google.note": "দ্রুত, কাঠামোগত প্রতিক্রিয়ার জন্য Gemini মডেল",
@@ -209,7 +208,6 @@ export const dict: Record<string, string> = {
"dialog.model.unpaid.viewMoreProviders": "আরও 70+ প্রদানকারী দেখুন",
"dialog.provider.viewAll": "আরো প্রদানকারী দেখান",
"provider.connect.title": "{{provider}} সংযোগ করুন",
"provider.connect.title.anthropicProMax": "Claude Pro/Max দিয়ে লগইন করুন",
"provider.connect.selectMethod": "{{provider}} এর জন্য লগইন পদ্ধতি নির্বাচন করুন।",
"provider.connect.method.apiKey": "API কী",
"provider.connect.method.browser": "ব্রাউজার",
@@ -375,8 +373,6 @@ export const dict: Record<string, string> = {
"prompt.attachment.remove": "সংযুক্তি সরান",
"prompt.action.send": "পাঠান",
"prompt.action.stop": "থামো",
"prompt.toast.pasteUnsupported.title": "অসমর্থিত সংযুক্তি",
"prompt.toast.pasteUnsupported.description": "এখানে শুধুমাত্র ছবি, পিডিএফ বা টেক্সট ফাইল সংযুক্ত করা যাবে।",
"prompt.toast.attachmentDuplicate.title": "এই ফাইল ইতিমধ্যে আপলোড করা হয়েছে",
"prompt.toast.modelAgentRequired.title": "একটি এজেন্ট এবং মডেল নির্বাচন করুন",
"prompt.toast.modelAgentRequired.description": "প্রম্পট পাঠানোর আগে একটি এজেন্ট এবং মডেল বেছে নিন।",
-4
View File
@@ -200,7 +200,6 @@ export const dict = {
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
"dialog.provider.anthropic.note": "Acesso direto aos modelos Claude, incluindo Pro e Max",
"dialog.provider.copilot.note": "Modelos de IA para auxílio à programação pelo GitHub Copilot",
"dialog.provider.openai.note": "Modelos GPT rápidos e avançados para tarefas gerais de IA",
"dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas",
@@ -217,7 +216,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Ver mais de 70 provedores",
"dialog.provider.viewAll": "Ver mais provedores",
"provider.connect.title": "Conectar {{provider}}",
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
"provider.connect.selectMethod": "Selecionar método de login para {{provider}}.",
"provider.connect.method.apiKey": "Chave de API",
"provider.connect.method.browser": "Navegador",
@@ -382,9 +380,7 @@ export const dict = {
"prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
"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",
-4
View File
@@ -208,7 +208,6 @@ export const dict = {
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
"dialog.provider.opencodeGo.tagline": "Povoljna pretplata za sve",
"dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max",
"dialog.provider.copilot.note": "AI modeli za pomoć pri kodiranju putem GitHub Copilot",
"dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke",
"dialog.provider.google.note": "Gemini modeli za brze, strukturirane odgovore",
@@ -229,7 +228,6 @@ export const dict = {
"dialog.provider.viewAll": "Prikaži više provajdera",
"provider.connect.title": "Poveži {{provider}}",
"provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max",
"provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.",
"provider.connect.method.apiKey": "API ključ",
"provider.connect.method.browser": "Preglednik",
@@ -403,9 +401,7 @@ export const dict = {
"prompt.action.send": "Pošalji",
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"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",
-4
View File
@@ -193,7 +193,6 @@ export const dict = {
"dialog.provider.opencode.note": "Models seleccionats que inclouen Claude, GPT, Gemini i més",
"dialog.provider.opencode.tagline": "Models optimitzats fiables",
"dialog.provider.opencodeGo.tagline": "Subscripció de baix cost per a tothom",
"dialog.provider.anthropic.note": "Accés directe a Claude models, inclosos Pro i Max",
"dialog.provider.copilot.note": "Models d'IA per a l'assistència de codificació mitjançant GitHub Copilot",
"dialog.provider.openai.note": "GPT models per a tasques d'IA generals ràpides i capaces",
"dialog.provider.google.note": "Gemini models per a respostes ràpides i estructurades",
@@ -210,7 +209,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Consulta més de 70 proveïdors més",
"dialog.provider.viewAll": "Mostra més proveïdors",
"provider.connect.title": "Connecta {{provider}}",
"provider.connect.title.anthropicProMax": "Inicieu sessió amb Claude Pro/Max",
"provider.connect.selectMethod": "Seleccioneu el mètode d'inici de sessió per a {{provider}}.",
"provider.connect.method.apiKey": "tecla API.",
"provider.connect.method.browser": "Navegador",
@@ -377,8 +375,6 @@ export const dict = {
"prompt.attachment.remove": "Elimina el fitxer adjunt",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Atureu-vos",
"prompt.toast.pasteUnsupported.title": "Fitxer adjunt no compatible",
"prompt.toast.pasteUnsupported.description": "Aquí només es poden adjuntar imatges, PDFs o fitxers de text.",
"prompt.toast.attachmentDuplicate.title": "Aquest fitxer ja s'ha penjat",
"prompt.toast.modelAgentRequired.title": "Seleccioneu un agent i un model",
"prompt.toast.modelAgentRequired.description": "Trieu un agent i un model abans d'enviar una sol·licitud.",
-4
View File
@@ -191,7 +191,6 @@ export const dict = {
"dialog.provider.opencode.note": "Vybrané modely včetně Claude, GPT, Gemini a dalších",
"dialog.provider.opencode.tagline": "Spolehlivé optimalizované modely",
"dialog.provider.opencodeGo.tagline": "Nízkonákladové předplatné pro každého",
"dialog.provider.anthropic.note": "Přímý přístup k modelům Claude, včetně modelů Pro a Max",
"dialog.provider.copilot.note": "Modely umělé inteligence pro pomoc s kódováním prostřednictvím GitHub Copilot",
"dialog.provider.openai.note": "GPT modely pro rychlé a schopné obecné úlohy umělé inteligence",
"dialog.provider.google.note": "Gemini modely pro rychlé, strukturované odpovědi",
@@ -208,7 +207,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Zobrazit více než 70 dalších poskytovatelů",
"dialog.provider.viewAll": "Zobrazit další poskytovatele",
"provider.connect.title": "Připojit {{provider}}",
"provider.connect.title.anthropicProMax": "Přihlaste se pomocí Claude Pro/Max",
"provider.connect.selectMethod": "Vyberte způsob přihlášení pro {{provider}}.",
"provider.connect.method.apiKey": "API klíč",
"provider.connect.method.browser": "Prohlížeč",
@@ -375,8 +373,6 @@ export const dict = {
"prompt.attachment.remove": "Odstraňte přílohu",
"prompt.action.send": "Odeslat",
"prompt.action.stop": "Přestaň",
"prompt.toast.pasteUnsupported.title": "Nepodporovaná příloha",
"prompt.toast.pasteUnsupported.description": "Zde lze připojit pouze obrázky, PDFs nebo textové soubory.",
"prompt.toast.attachmentDuplicate.title": "Tento soubor již byl nahrán",
"prompt.toast.modelAgentRequired.title": "Vyberte agenta a model",
"prompt.toast.modelAgentRequired.description": "Před odesláním výzvy vyberte zástupce a model.",
-4
View File
@@ -107,7 +107,6 @@ export const dict = {
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
"dialog.provider.opencodeGo.tagline": "Billigt abonnement for alle",
"dialog.provider.anthropic.note": "Direkte adgang til Claude-modeller, inklusive Pro og Max",
"dialog.provider.copilot.note": "AI-modeller til kodningsassistance via GitHub Copilot",
"dialog.provider.openai.note": "GPT-modeller til hurtige, kompetente generelle AI-opgaver",
"dialog.provider.google.note": "Gemini-modeller til hurtige, strukturerede svar",
@@ -128,7 +127,6 @@ export const dict = {
"dialog.provider.viewAll": "Vis flere udbydere",
"provider.connect.title": "Forbind {{provider}}",
"provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max",
"provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.",
"provider.connect.method.apiKey": "API-nøgle",
"provider.connect.method.browser": "Browser",
@@ -300,9 +298,7 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
"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",
-4
View File
@@ -103,7 +103,6 @@ export const dict = {
"dialog.provider.opencode.note": "Kuratierte Modelle inklusive Claude, GPT, Gemini und mehr",
"dialog.provider.opencode.tagline": "Zuverlässige, optimierte Modelle",
"dialog.provider.opencodeGo.tagline": "Kostengünstiges Abo für alle",
"dialog.provider.anthropic.note": "Direkter Zugriff auf Claude-Modelle, einschließlich Pro und Max",
"dialog.provider.copilot.note": "KI-Modelle zur Programmierunterstützung über GitHub Copilot",
"dialog.provider.openai.note": "GPT-Modelle für schnelle, leistungsfähige allgemeine KI-Aufgaben",
"dialog.provider.google.note": "Gemini-Modelle für schnelle, strukturierte Antworten",
@@ -120,7 +119,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Über 70 weitere Anbieter anzeigen",
"dialog.provider.viewAll": "Mehr Anbieter anzeigen",
"provider.connect.title": "{{provider}} verbinden",
"provider.connect.title.anthropicProMax": "Mit Claude Pro/Max anmelden",
"provider.connect.selectMethod": "Anmeldemethode für {{provider}} auswählen.",
"provider.connect.method.apiKey": "API-Schlüssel",
"provider.connect.method.browser": "Browser",
@@ -286,9 +284,7 @@ export const dict = {
"prompt.attachment.remove": "Anhang entfernen",
"prompt.action.send": "Senden",
"prompt.action.stop": "Stoppen",
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description":
"Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",
-5
View File
@@ -195,7 +195,6 @@ export const dict = {
"ކިއުރެޓް ކުރެވިފައިވާ މޮޑެލްތަކުގެ ތެރޭގައި Claude، GPT، Gemini އަދި އެހެނިހެން މޮޑެލްތައް ހިމެނެއެވެ",
"dialog.provider.opencode.tagline": "އިތުބާރު ހުރި އޮޕްޓިމައިޒްޑް މޮޑެލްތަކެވެ",
"dialog.provider.opencodeGo.tagline": "އެންމެންނަށް ކުޑަ އަގެއްގައި ސަބްސްކްރިޕްޝަން ދިނުމެވެ",
"dialog.provider.anthropic.note": "ޕްރޯ އަދި މެކްސް ހިމެނޭ ގޮތަށް Claude މޮޑެލްތަކަށް ސީދާ އެކްސެސް ލިބޭނެ އެވެ",
"dialog.provider.copilot.note": "GitHub Copilot މެދުވެރިކޮށް ކޯޑިންގ އެހީތެރިވުމަށް AI މޮޑެލްތައް",
"dialog.provider.openai.note": "އަވަސް، ގާބިލް އާންމު AI ޓާސްކްތަކަށް GPT މޮޑެލްތަކެވެ",
"dialog.provider.google.note": "އަވަސް، ސްޓްރަކްޗަރޑް ރެސްޕޮންސްތަކަށް Gemini މޮޑެލްތަކެވެ",
@@ -212,7 +211,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "އިތުރު 70+ ޕްރޮވައިޑަރުން ބައްލަވާށެވެ",
"dialog.provider.viewAll": "އިތުރު ޕްރޮވައިޑަރުން ދައްކާށެވެ",
"provider.connect.title": "{{provider}} ގުޅުވާށެވެ",
"provider.connect.title.anthropicProMax": "Claude ޕްރޯ/މެކްސް އިން ލޮގިން ވާނެއެވެ",
"provider.connect.selectMethod": "{{provider}} އަށް ލޮގިން މެތޯޑް ހޮވާށެވެ.",
"provider.connect.method.apiKey": "API ކީ އެވެ",
"provider.connect.method.browser": "ބްރައުޒާ އެވެ",
@@ -380,9 +378,6 @@ export const dict = {
"prompt.attachment.remove": "އެޓޭޗްމަންޓް ނަގާށެވެ",
"prompt.action.send": "ފޮނުވުން",
"prompt.action.stop": "ހުއްޓުން",
"prompt.toast.pasteUnsupported.title": "ސަޕޯޓް ނުކުރާ އެޓޭޗްމަންޓެވެ",
"prompt.toast.pasteUnsupported.description":
"މިތަނުގައި އެޓޭޗް ކުރެވޭނީ ހަމައެކަނި ތަސްވީރު، PDFs، ނުވަތަ ޓެކްސްޓް ފައިލްތަކެވެ.",
"prompt.toast.attachmentDuplicate.title": "މި ފައިލް މިހާރު ވަނީ އަޕްލޯޑްކޮށްފައެވެ",
"prompt.toast.modelAgentRequired.title": "އޭޖެންޓަކާއި މޮޑެލްއެއް ހޮވުން",
"prompt.toast.modelAgentRequired.description": "ޕްރޮމްޕްޓެއް ފޮނުވުމުގެ ކުރިން އޭޖެންޓަކާއި މޮޑެލްއެއް ހޮވުން.",
-5
View File
@@ -194,7 +194,6 @@ export const dict: Record<string, string> = {
"dialog.provider.opencode.note": "Claude དང་ GPT དེ་ལས་ Gemini དང་དེ་ལས་ལྷག་སྟེ་ བཀོད་སྒྲིག་འབད་ཡོད་པའི་དཔེ་ཚད།",
"dialog.provider.opencode.tagline": "བློ་གཏད་ཅན་གྱི་ཡར་རྒྱས་ཅན་གྱི་དཔེ་ཚད་ཚུ།",
"dialog.provider.opencodeGo.tagline": "མི་ཚང་མའི་དོན་ལུ་ གོང་ཚད་དམའ་བའི་ མཁོ་མངགས་འབད་ནི།",
"dialog.provider.anthropic.note": "པྲོ་དང་མེགསི་ཚུ་རྩིས་ཏེ་ Claudeདཔེ་ཚད་ཚུ་ལུ་ཐད་ཀར་དུ་འཛུལ་སྤྱོད།",
"dialog.provider.copilot.note": "GitHub Copilot བརྒྱུད་དེ་ ཀོ་ཌིང་གྲོགས་རམ་གྱི་དོན་ལུ་ AI དཔེ་ཚད།",
"dialog.provider.openai.note": "GPT མགྱོགས་དྲགས་དང་ལྕོགས་གྲུབ་ཅན་གྱི་སྤྱིར་བཏང་ཨེ་ཨའི་ལས་འགན་ཚུ་གི་དོན་ལུ་དཔེ་ཚད།",
"dialog.provider.google.note": "Gemini མགྱོགས་དྲགས་དང་སྒྲིག་བཀོད་ཅན་གྱི་ལན་འདེབས་ཀྱི་དཔེ་ཚད།",
@@ -212,7 +211,6 @@ export const dict: Record<string, string> = {
"dialog.model.unpaid.viewMoreProviders": "70+ མཁོ་སྤྲོད་འབད་མི་ཚུ་བལྟ།",
"dialog.provider.viewAll": "བྱིན་མི་མངམ་སྟོན།",
"provider.connect.title": "{{provider}}མཐུད།",
"provider.connect.title.anthropicProMax": "Claude Pro/Max དང་ཅིག་ཁར་ནང་བསྐྱོན།",
"provider.connect.selectMethod": "{{provider}}གི་དོན་ལུ་ ནང་བསྐྱོད་ཐབས་ལམ་སེལ་འཐུ་འབད།",
"provider.connect.method.apiKey": "APIལྡེ་མིག།",
"provider.connect.method.browser": "བརའུ་ཟར།",
@@ -379,9 +377,6 @@ export const dict: Record<string, string> = {
"prompt.attachment.remove": "མཉམ་སྦྲགས་རྩ་བསྐྲད་གཏང་།",
"prompt.action.send": "བཏང༌ནི",
"prompt.action.stop": "བཀག་པ",
"prompt.toast.pasteUnsupported.title": "རྒྱབ་སྐྱོར་མེད་པའི་མཉམ་སྦྲགས།",
"prompt.toast.pasteUnsupported.description":
"པར་རིས་དང་པི་ཌི་ཨེཕ་ ཡང་ན་ ཚིག་ཡིག་ཡིག་སྣོད་ཚུ་རྐྱངམ་ཅིག་ ནཱ་ལུ་མཉམ་སྦྲགས་འབད་བཏུབ།",
"prompt.toast.attachmentDuplicate.title": "ཡིག་སྣོད་འདི་ཧེ་མ་ལས་སྐྱེལ་བཙུགས་འབད་ཡི།",
"prompt.toast.modelAgentRequired.title": "ལས་ཚབ་དང་དཔེ་ཚད་ཅིག་སེལ་འཐུ་འབད།",
"prompt.toast.modelAgentRequired.description": "བརྡ་སྟོན་མ་གཏང་པའི་ཧེ་མ་ ལས་ཚབ་དང་དཔེ་ཚད་གདམ་ཁ་རྐྱབས།",
-4
View File
@@ -192,7 +192,6 @@ export const dict = {
"dialog.provider.opencode.note": "Επιμελημένα μοντέλα συμπεριλαμβανομένων των Claude, GPT, Gemini και άλλων",
"dialog.provider.opencode.tagline": "Αξιόπιστα βελτιστοποιημένα μοντέλα",
"dialog.provider.opencodeGo.tagline": "Συνδρομή χαμηλού κόστους για όλους",
"dialog.provider.anthropic.note": "Άμεση πρόσβαση σε Claude μοντέλα, συμπεριλαμβανομένων των Pro και Max",
"dialog.provider.copilot.note": "Μοντέλα AI για βοήθεια κωδικοποίησης μέσω GitHub Copilot",
"dialog.provider.openai.note": "GPT μοντέλα για γρήγορες, ικανές εργασίες γενικής τεχνητής νοημοσύνης",
"dialog.provider.google.note": "Gemini μοντέλα για γρήγορες, δομημένες απαντήσεις",
@@ -209,7 +208,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Δείτε 70+ ακόμη παρόχους",
"dialog.provider.viewAll": "Εμφάνιση περισσότερων παρόχων",
"provider.connect.title": "Σύνδεση {{provider}}",
"provider.connect.title.anthropicProMax": "Σύνδεση με Claude Pro/Max",
"provider.connect.selectMethod": "Επιλογή μεθόδου σύνδεσης για {{provider}}.",
"provider.connect.method.apiKey": "API κλειδί",
"provider.connect.method.browser": "Πρόγραμμα περιήγησης",
@@ -376,8 +374,6 @@ export const dict = {
"prompt.attachment.remove": "Κατάργηση συνημμένου",
"prompt.action.send": "Αποστολή",
"prompt.action.stop": "Διακοπή",
"prompt.toast.pasteUnsupported.title": "Μη υποστηριζόμενο συνημμένο",
"prompt.toast.pasteUnsupported.description": "Εδώ επισυνάπτονται μόνο εικόνες, αρχεία PDF ή αρχεία κειμένου.",
"prompt.toast.attachmentDuplicate.title": "Αυτό το αρχείο έχει ήδη μεταφορτωθεί",
"prompt.toast.modelAgentRequired.title": "Επιλέξτε έναν πράκτορα και μοντέλο",
"prompt.toast.modelAgentRequired.description":
+16 -34
View File
@@ -74,6 +74,7 @@ export const dict = {
"command.category.workspace": "Worktree",
"command.category.settings": "Settings",
"command.logs.export": "Export logs",
"command.debugBar.toggle": "Toggle debug bar",
"theme.scheme.system": "System",
"theme.scheme.light": "Light",
@@ -93,6 +94,7 @@ export const dict = {
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session",
"command.session.location.cycle": "Cycle session location",
"command.palette": "Command palette",
@@ -169,7 +171,7 @@ export const dict = {
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
"dialog.provider.opencode.tagline": "Reliable optimized models",
"dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone",
"dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max",
"dialog.provider.anthropic.note": "Direct access to Claude models via API key",
"dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot",
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
"dialog.provider.google.note": "Gemini models for fast, structured responses",
@@ -190,7 +192,7 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.title.anthropicProMax": "Login with Claude Pro/Max",
"provider.connect.title.anthropicProMax": "Login with Anthropic",
"provider.connect.selectMethod": "Select login method for {{provider}}.",
"provider.connect.method.apiKey": "API key",
"provider.connect.method.browser": "Browser",
@@ -364,9 +366,10 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.uploading.percent": "{{percent}}%",
"prompt.toast.uploading.cancel": "Cancel upload",
"prompt.toast.uploadFailed.title": "Upload failed",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
@@ -437,36 +440,6 @@ export const dict = {
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"command.server.pair": "Pair device",
"settings.pairing.title": "Pairing",
"settings.pairing.sshUnavailable": "Pairing is not available for SSH servers.",
"settings.pairing.connection": "Local Network",
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
"pair.local.open": "Show details",
"pair.screenActive.title": "Keep screen active",
"pair.screenActive.description": "Prevent this computers display from sleeping while OpenCode is running.",
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
"pair.qr.open": "Show details",
"pair.tailscale.enable": "Enable Tailscale",
"pair.tailscale.serve": "Tailscale Serve",
"pair.description": "Connect another device to this machine's OpenCode server.",
"pair.loading": "Loading connection details…",
"pair.qr": "Pairing QR code",
"pair.copy": "Copy details",
"pair.copy.error": "Could not copy pairing details. Try again.",
"pair.urls": "URLs",
"pair.username": "Username",
"pair.password": "Password",
"pair.error": "Could not update pairing details. Try again.",
"pair.tailscale.open": "Share with Tailscale",
"pair.tailscale.opening": "Enabling Tailscale Serve…",
"pair.tailscale.title": "Tailscale",
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
"pair.tailscale.checking": "Checking Tailscale Serve…",
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
"pair.tailscale.disable": "Disable",
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -994,7 +967,16 @@ export const dict = {
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.providerAriaLabel": "Provider performance diagnostics",
"debugBar.na": "n/a",
"debugBar.ttft.label": "TTFT",
"debugBar.ttft.tip": "Time from provider request dispatch to the first model output.",
"debugBar.ttfa.label": "TTFA",
"debugBar.ttfa.tip": "Time from provider request dispatch to the first answer text.",
"debugBar.tps.label": "TPS",
"debugBar.tps.tip": "Output tokens per second after the first model output.",
"debugBar.e2e.label": "E2E",
"debugBar.e2e.tip": "Time from provider request dispatch until its response stream ended.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
-5
View File
@@ -208,7 +208,6 @@ export const dict = {
"dialog.provider.opencode.note": "Selección de modelos como Claude, GPT, Gemini y otros",
"dialog.provider.opencode.tagline": "Modelos optimizados y fiables",
"dialog.provider.opencodeGo.tagline": "Suscripción económica para todos",
"dialog.provider.anthropic.note": "Acceso directo a modelos Claude, incluidos Pro y Max",
"dialog.provider.copilot.note": "Modelos de IA para asistencia de codificación a través de GitHub Copilot",
"dialog.provider.openai.note": "Modelos GPT para tareas de IA generales rápidas y capaces",
"dialog.provider.google.note": "Modelos Gemini para respuestas rápidas y estructuradas",
@@ -229,7 +228,6 @@ export const dict = {
"dialog.provider.viewAll": "Ver más proveedores",
"provider.connect.title": "Conectar {{provider}}",
"provider.connect.title.anthropicProMax": "Iniciar sesión con Claude Pro/Max",
"provider.connect.selectMethod": "Seleccionar método de inicio de sesión para {{provider}}.",
"provider.connect.method.apiKey": "Clave API",
"provider.connect.method.browser": "Navegador",
@@ -403,10 +401,7 @@ export const dict = {
"prompt.action.send": "Enviar",
"prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
"prompt.toast.pasteUnsupported.description":
"Aquí solo se pueden adjuntar imágenes, archivos PDF o archivos de texto.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
"prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo",
-4
View File
@@ -191,7 +191,6 @@ export const dict = {
"dialog.provider.opencode.note": "Kureeritud mudelid, sealhulgas Claude, GPT, Gemini ja palju muud",
"dialog.provider.opencode.tagline": "Usaldusväärsed optimeeritud mudelid",
"dialog.provider.opencodeGo.tagline": "Madala hinnaga tellimus kõigile",
"dialog.provider.anthropic.note": "Otsene juurdepääs Claude mudelile, sealhulgas Pro ja Max",
"dialog.provider.copilot.note": "AI mudelid kodeerimisabiks GitHub Copilot kaudu",
"dialog.provider.openai.note": "GPT mudelid kiirete ja võimekate üldiste AI-ülesannete jaoks",
"dialog.provider.google.note": "Gemini mudelid kiirete ja struktureeritud vastuste jaoks",
@@ -208,7 +207,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Vaadake veel 70+ pakkujat",
"dialog.provider.viewAll": "Kuva rohkem teenusepakkujaid",
"provider.connect.title": "Ühenda {{provider}}",
"provider.connect.title.anthropicProMax": "Logi sisse Claude Pro/Max",
"provider.connect.selectMethod": "Valige {{provider}} jaoks sisselogimisviis.",
"provider.connect.method.apiKey": "API klahv",
"provider.connect.method.browser": "Brauser",
@@ -374,8 +372,6 @@ export const dict = {
"prompt.attachment.remove": "Eemalda manus",
"prompt.action.send": "Saada",
"prompt.action.stop": "Peatus",
"prompt.toast.pasteUnsupported.title": "Toetamata manus",
"prompt.toast.pasteUnsupported.description": "Siia saab lisada ainult pilte, PDFs või tekstifaile.",
"prompt.toast.attachmentDuplicate.title": "See fail on juba üles laaditud",
"prompt.toast.modelAgentRequired.title": "Valige agent ja mudel",
"prompt.toast.modelAgentRequired.description": "Enne viipa saatmist valige agent ja mudel.",
-4
View File
@@ -192,7 +192,6 @@ export const dict = {
"dialog.provider.opencode.note": "مدل های انتخاب شده از جمله Claude، GPT، Gemini و موارد دیگر",
"dialog.provider.opencode.tagline": "مدل های بهینه شده قابل اعتماد",
"dialog.provider.opencodeGo.tagline": "اشتراک کم هزینه برای همه",
"dialog.provider.anthropic.note": "دسترسی مستقیم به مدل های Claude، از جمله Pro و Max",
"dialog.provider.copilot.note": "مدل های AI برای کمک به کدنویسی از طریق GitHub Copilot",
"dialog.provider.openai.note": "مدل های GPT برای کارهای عمومی سریع و با قابلیت AI",
"dialog.provider.google.note": "مدل های Gemini برای پاسخ های سریع و ساختاریافته",
@@ -209,7 +208,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "70+ ارائه‌دهنده دیگر را ببینید",
"dialog.provider.viewAll": "نمایش ارائه دهندگان بیشتر",
"provider.connect.title": "{{provider}} را وصل کنید",
"provider.connect.title.anthropicProMax": "با Claude Pro/Max وارد شوید",
"provider.connect.selectMethod": "روش ورود برای {{provider}} را انتخاب کنید.",
"provider.connect.method.apiKey": "کلید API",
"provider.connect.method.browser": "مرورگر",
@@ -375,8 +373,6 @@ export const dict = {
"prompt.attachment.remove": "حذف پیوست",
"prompt.action.send": "ارسال کنید",
"prompt.action.stop": "توقف کنید",
"prompt.toast.pasteUnsupported.title": "پیوست پشتیبانی نشده است",
"prompt.toast.pasteUnsupported.description": "فقط تصاویر، PDFs، یا فایل های متنی را می توان در اینجا پیوست کرد.",
"prompt.toast.attachmentDuplicate.title": "این فایل قبلا آپلود شده است",
"prompt.toast.modelAgentRequired.title": "یک عامل و مدل را انتخاب کنید",
"prompt.toast.modelAgentRequired.description": "قبل از ارسال درخواست، یک عامل و مدل را انتخاب کنید.",
-4
View File
@@ -99,7 +99,6 @@ export const dict = {
"dialog.provider.opencode.note": "Kuratoituja malleja, kuten Claude, GPT, Gemini ja muita",
"dialog.provider.opencode.tagline": "Luotettavat optimoidut mallit",
"dialog.provider.opencodeGo.tagline": "Edullinen tilaus kaikille",
"dialog.provider.anthropic.note": "Suora pääsy Claude-malleihin, mukaan lukien Pro ja Max",
"dialog.provider.copilot.note": "AI-malleja koodausapuun GitHub Copilotin kautta",
"dialog.provider.openai.note": "GPT-mallit nopeisiin, suorituskykyisiin yleisiin tekoälytehtäviin",
"dialog.provider.google.note": "Gemini-mallit nopeisiin, jäsenneltyihin vastauksiin",
@@ -116,7 +115,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Katso yli 70 muuta palveluntarjoajaa",
"dialog.provider.viewAll": "Näytä lisää palveluntarjoajia",
"provider.connect.title": "Yhdistä {{provider}}",
"provider.connect.title.anthropicProMax": "Kirjaudu sisään Claude Pro/Maxilla",
"provider.connect.selectMethod": "Valitse kirjautumistapa palvelulle {{provider}}.",
"provider.connect.method.apiKey": "API-avain",
"provider.connect.method.browser": "Selain",
@@ -282,8 +280,6 @@ export const dict = {
"prompt.attachment.remove": "Poista liite",
"prompt.action.send": "Lähetä",
"prompt.action.stop": "Pysäytä",
"prompt.toast.pasteUnsupported.title": "Liitettä ei tueta",
"prompt.toast.pasteUnsupported.description": "Vain kuvia, PDF-tiedostoja tai tekstitiedostoja voi liittää tähän.",
"prompt.toast.attachmentDuplicate.title": "Tämä tiedosto on jo ladattu",
"prompt.toast.modelAgentRequired.title": "Valitse agentti ja malli",
"prompt.toast.modelAgentRequired.description": "Valitse agentti ja malli ennen kehotteen lähettämistä.",
-4
View File
@@ -191,7 +191,6 @@ export const dict = {
"dialog.provider.opencode.note": "Kuraterað modell íroknað Claude, GPT, Gemini og fleiri",
"dialog.provider.opencode.tagline": "Álítandi optimerað modell",
"dialog.provider.opencodeGo.tagline": "Lágkostnaðarhald fyri øll",
"dialog.provider.anthropic.note": "Beinleiðis atgongd til Claude modellir, eisini Pro og Max .",
"dialog.provider.copilot.note": "AI modellir til koduhjálp umvegis GitHub Copilot",
"dialog.provider.openai.note": "GPT modellir til skjótar, førar almennar AI uppgávur",
"dialog.provider.google.note": "Gemini modellir til skjót, strukturerað svar",
@@ -208,7 +207,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Sí 70+ fleiri veitarar",
"dialog.provider.viewAll": "Vís fleiri veitarar",
"provider.connect.title": "Samband {{provider}}",
"provider.connect.title.anthropicProMax": "Rita inn við Claude Pro/Max",
"provider.connect.selectMethod": "Vel innritanarhátt til {{provider}}.",
"provider.connect.method.apiKey": "API lykil",
"provider.connect.method.browser": "Kagari",
@@ -374,8 +372,6 @@ export const dict = {
"prompt.attachment.remove": "Strika viðheftið",
"prompt.action.send": "Send",
"prompt.action.stop": "Steðga",
"prompt.toast.pasteUnsupported.title": "Óstuðlað viðhefti",
"prompt.toast.pasteUnsupported.description": "Bert myndir, PDFs, ella tekstfílur kunnu viðheftast her.",
"prompt.toast.attachmentDuplicate.title": "Hendan fílan er longu løgd upp.",
"prompt.toast.modelAgentRequired.title": "Vel agent og modell",
"prompt.toast.modelAgentRequired.description": "Vel agent og modell, áðrenn tú sendir ein prompt.",
-5
View File
@@ -200,7 +200,6 @@ export const dict = {
"dialog.provider.opencode.note": "Modèles sélectionnés incluant Claude, GPT, Gemini et plus",
"dialog.provider.opencode.tagline": "Modèles optimisés et fiables",
"dialog.provider.opencodeGo.tagline": "Abonnement abordable pour tous",
"dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API",
"dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API",
"dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API",
"dialog.provider.google.note": "Modèles Gemini pour des réponses rapides et structurées",
@@ -217,7 +216,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "Voir plus de 70 autres fournisseurs",
"dialog.provider.viewAll": "Voir plus de fournisseurs",
"provider.connect.title": "Connecter {{provider}}",
"provider.connect.title.anthropicProMax": "Connexion avec Claude Pro/Max",
"provider.connect.selectMethod": "Sélectionnez la méthode de connexion pour {{provider}}.",
"provider.connect.method.apiKey": "Clé API",
"provider.connect.method.browser": "Navigateur",
@@ -385,10 +383,7 @@ export const dict = {
"prompt.attachment.remove": "Supprimer la pièce jointe",
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
"prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
"prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer une invite.",
"prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail",
-4
View File
@@ -192,7 +192,6 @@ export const dict = {
"dialog.provider.opencode.note": "מבחר מודלים, בהם Claude, GPT, Gemini ועוד",
"dialog.provider.opencode.tagline": "מודלים אמינים שעברו אופטימיזציה",
"dialog.provider.opencodeGo.tagline": "מנוי בעלות נמוכה לכולם",
"dialog.provider.anthropic.note": "גישה ישירה למודלי Claude, כולל Pro ו-Max",
"dialog.provider.copilot.note": "מודלי AI לסיוע בכתיבת קוד באמצעות GitHub Copilot",
"dialog.provider.openai.note": "מודלי GPT למשימות AI כלליות, מהירות ומתקדמות",
"dialog.provider.google.note": "מודלי Gemini לתגובות מהירות ומובנות",
@@ -209,7 +208,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "הצגת יותר מ-70 ספקים נוספים",
"dialog.provider.viewAll": "הצג עוד ספקים",
"provider.connect.title": "חבר את {{provider}}",
"provider.connect.title.anthropicProMax": "התחבר עם Claude Pro/Max",
"provider.connect.selectMethod": "בחר שיטת התחברות עבור {{provider}}.",
"provider.connect.method.apiKey": "מפתח API",
"provider.connect.method.browser": "דפדפן",
@@ -373,8 +371,6 @@ export const dict = {
"prompt.attachment.remove": "הסר את הקובץ המצורף",
"prompt.action.send": "שלח",
"prompt.action.stop": "עצור",
"prompt.toast.pasteUnsupported.title": "קובץ מצורף לא נתמך",
"prompt.toast.pasteUnsupported.description": "ניתן לצרף כאן רק תמונות, קובצי PDF או קבצי טקסט.",
"prompt.toast.attachmentDuplicate.title": "הקובץ הזה כבר הועלה",
"prompt.toast.modelAgentRequired.title": "בחר סוכן ומודל",
"prompt.toast.modelAgentRequired.description": "יש לבחור סוכן ומודל לפני שליחת פרומפט.",
-4
View File
@@ -199,7 +199,6 @@ export const dict = {
"dialog.provider.opencode.note": "Claude, GPT, Gemini और अधिक सहित क्यूरेटेड मॉडल",
"dialog.provider.opencode.tagline": "विश्वसनीय अनुकूलित मॉडल",
"dialog.provider.opencodeGo.tagline": "सभी के लिए कम लागत वाली सदस्यता",
"dialog.provider.anthropic.note": "प्रो और मैक्स सहित Claude मॉडल तक सीधी पहुँच",
"dialog.provider.copilot.note": "GitHub Copilot के माध्यम से कोडिंग सहायता के लिए AI मॉडल",
"dialog.provider.openai.note": "तेज़, सक्षम सामान्य AI कार्यों के लिए GPT मॉडल",
"dialog.provider.google.note": "तेज़, संरचित प्रतिक्रियाओं के लिए Gemini मॉडल",
@@ -216,7 +215,6 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "70+ अधिक प्रोवाइडर देखें",
"dialog.provider.viewAll": "और प्रोवाइडर दिखाएँ",
"provider.connect.title": "{{provider}} कनेक्ट करें",
"provider.connect.title.anthropicProMax": "Claude प्रो/मैक्स के साथ लॉगिन करें",
"provider.connect.selectMethod": "{{provider}} के लिए लॉगिन विधि चुनें।",
"provider.connect.method.apiKey": "API कुंजी",
"provider.connect.method.browser": "ब्राउज़र",
@@ -382,8 +380,6 @@ export const dict = {
"prompt.attachment.remove": "अनुलग्नक हटाएँ",
"prompt.action.send": "भेजें",
"prompt.action.stop": "रोकें",
"prompt.toast.pasteUnsupported.title": "असमर्थित अनुलग्नक",
"prompt.toast.pasteUnsupported.description": "यहां केवल छवियां, PDFs, या टेक्स्ट फ़ाइलें संलग्न की जा सकती हैं।",
"prompt.toast.attachmentDuplicate.title": "यह फ़ाइल पहले ही अपलोड की जा चुकी है",
"prompt.toast.modelAgentRequired.title": "एक एजेंट और मॉडल चुनें",
"prompt.toast.modelAgentRequired.description": "प्रॉम्प्ट भेजने से पहले एक एजेंट और मॉडल चुनें।",

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