Compare commits

...
Author SHA1 Message Date
neriousy 2356e7bbad feat(server): gate external integration values by client API version
Clients now send x-opencode-api-version. Clients that send no header
(v2.0.24 and earlier) reject external integration methods, connections,
and credentials, so integration.list, integration.get, and credential.list
omit those values for them instead of failing the whole response.
2026-10-07 23:01:06 +00:00
Luke Parker 46c69bc35c fix(session-ui): show latest thinking summary heading while streaming (#53815) 2026-10-08 08:24:05 +10:00
Aiden Cline be19c7612f fix(core): drop early guarded plugin activation (#53819) 2026-10-07 17:12:45 -05:00
Simon Klee 162bc6922b test: stabilize asynchronous UI and plugin tests (#53818) 2026-10-07 22:01:55 +00:00
Filip 87a1ebcc97 feat(core): connect Azure through the Azure CLI as an external credential (#53794) 2026-10-07 23:10:18 +02:00
opencode-agent[bot] 2138bfd02a chore: update nix node_modules hashes 2026-10-07 21:04:54 +00:00
Luke Parker 98d57d0dd2 fix(app): focus search input when opening /model dialog (#53707) 2026-10-08 06:56:27 +10:00
Simon Klee fcad1319bf chore: upgrade opentui v0.5.16 (#53805) 2026-10-07 20:54:17 +00:00
Dax Raad e3419d47ee fix(cli): keep state on uninstall 2026-10-07 16:45:59 -04:00
Adam d016db856c feat(core): enforce skill integration policies 2026-10-07 15:39:27 -05:00
Adam 613ef8ba87 feat(core): enforce managed integration and tool policies (#53775) 2026-10-07 15:39:26 -05:00
Dax Raad b8d3c92985 fix(cli): keep data and config on uninstall 2026-10-07 16:34:39 -04:00
opencode-agent[bot]andnexxeln 8587f817cc fix(tui): separate queued prompt dock from composer (#53801)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-10-08 01:45:54 +05:30
Simon Klee a98adc1ab0 fix(tui): stop dialogs reading stale transcript rows after resync (#53800) 2026-10-07 22:13:36 +02:00
Milosz JankiewiczandJaaneek 31bdd8e8f7 fix(core): only send png, jpeg and webp images to xAI (#53787)
Co-authored-by: Jaaneek <Jaaneek@users.noreply.github.com>
2026-10-07 21:46:09 +02:00
Frank cab7e2c242 docs(www): add Haiku 5.5 to V2 Go guide 2026-10-07 15:10:42 -04:00
Aiden Cline 56be008756 feat(core): add AWS profile setup for Bedrock (#53626) 2026-10-07 14:05:08 -05:00
Frank 554e7c2341 docs(www): add Haiku 5.5 to V2 model tables 2026-10-07 14:47:58 -04:00
opencode-agent[bot]andrekram1-node 5183ea45c3 fix(core): send Codex session headers with ChatGPT token sharing (#53767)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-07 11:32:57 -05:00
Dax 053e534e5a refactor(server): align pty connect preflight order (#53579) 2026-10-07 12:07:48 -04:00
opencode-agent[bot]andrekram1-node 8bd8960154 fix(ai): add missing OpenAI and Google service tiers (#53753)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-07 11:06:50 -05:00
holny d5154fb74e fix(core): bypass gray-matter content cache when parsing frontmatter (#51245) 2026-10-07 11:05:37 -05:00
Aiden Cline 70c6c8cbc5 feat(integration): add declarative external connection method (#53672) 2026-10-07 11:03:24 -05:00
NamitandJames Long e2979039f1 fix(core): bump session time_updated on step lifecycle events (#49105)
Co-authored-by: James Long <longster@gmail.com>
2026-10-07 11:40:47 -04:00
opencode-agent[bot]andBrendonovich 119cb15d1d refactor(app): remove configurable default server (#53731)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 20:46:51 +08:00
opencode-agent[bot] e1b7299bc9 chore(core): refresh bundled models.dev snapshot 2026-10-07 12:22:51 +00:00
opencode-agent[bot]andBrendonovich 68aa4f8017 feat(app): show recent sessions in the empty command palette (#53727)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 10:58:35 +00:00
David HillandBrendonovich 69219470a2 feat(app): show recently closed tabs menu (#51973)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 10:24:51 +00:00
opencode-agent[bot]andBrendonovich 10b11ba946 fix(app): keep live terminals through transient connect failures (#52232)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 10:04:03 +00:00
opencode-agent[bot]andBrendonovich 2e3d3a735a fix(app): use filled clear icon in model and branch searches (#53511)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 17:12:10 +08:00
opencode-agent[bot]andBrendonovich f655e7b2b5 fix(app): wrap mobile project controls responsively (#50484)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 17:11:57 +08:00
opencode-agent[bot]andneriousy 6273bd7924 fix(core): stop sending API key metadata as request body (#53546)
Co-authored-by: neriousy <filip@anoma.ly>
2026-10-07 10:36:22 +02:00
Aiden Cline 34cf183c4c feat(ai): support between_tools thinking in Anthropic Messages (#53601) 2026-10-06 23:57:20 -05:00
opencode-agent[bot]andBrendonovich fd99516e51 fix(session-ui): keep Markdown text selectable (#53665)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-07 04:33:34 +00:00
Aiden Cline 1d610f90a0 fix(ui): support inline custom answers for string choice fields in connect (#53625) 2026-10-06 22:41:21 -05:00
Luke Parker eebe9d370f fix(app): handle one-time pairing links across the GUI (#53257) 2026-10-07 12:56:07 +10:00
Luke Parker a2b6005627 fix(desktop): delete old staged CLI versions (#53659) 2026-10-07 02:10:05 +00:00
Luke Parker a90ad8566d fix(cli): serve the embedded web UI brotli-compressed (#53645) 2026-10-07 11:35:20 +10:00
Luke Parker a7f3d570ae fix(cli): compress the embedded web UI at brotli's best quality (#53644) 2026-10-07 11:35:20 +10:00
Luke Parker 8b45c5674b fix(cli): embed the web UI as raw bytes instead of base64 strings (#53643) 2026-10-07 11:35:19 +10:00
Frank 56c2f432cc docs(www): sync Mistral Large 4 pricing and privacy 2026-10-06 21:27:39 -04:00
Kit Langton 74678c4677 feat(server): stream fs.read responses with HTTP Range support (#53088) 2026-10-06 18:24:55 -07:00
Aiden Cline 369aad308b feat(core): add external credential references (#53624) 2026-10-06 19:55:05 -05:00
opencode-agent[bot] d5cf9c2548 chore: update nix node_modules hashes 2026-10-07 00:40:27 +00:00
Luke Parker 9c51d84f8f feat(app): preview Word, Excel and PowerPoint files (#53305) 2026-10-07 00:31:01 +00:00
Aiden Cline 8b02c4ef04 fix(ai): drop empty unfinished reasoning items on replay (#53603) 2026-10-06 19:03:35 -05:00
Luke Parker e41256eb5a fix(desktop): fix browser bar review findings (#53627) 2026-10-07 09:50:13 +10:00
Daniel Chen d1749770c7 docs(www): add Exo Free to Console models (#53620) 2026-10-06 15:27:58 -07:00
Kit Langton 9ee97d87a2 fix(sdk): release completed embedded request signals (#53613) 2026-10-06 15:05:36 -07:00
59e5953443 fix(schema): expose effort-update and thinking-binding overrides on model compatibility (#52172)
Co-authored-by: argszero <argszero@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-06 15:41:12 -05:00
Dax 4a0f273fa7 feat(cli): include every pairing address in QR codes (#53588) 2026-10-06 16:13:34 -04:00
Dax 36a38058fb feat(core): stop asking for external directory access by default (#53585) 2026-10-06 15:52:23 -04:00
Dax ccb5162714 fix(tui): match execute dialog to two-toned dialog layout (#53584) 2026-10-06 15:20:21 -04:00
opencode-agent[bot]andrekram1-node c2f87853c6 fix(core): drop tool name and namespace length caps (#53586)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-10-06 14:15:40 -05:00
Shoubhit Dash ed0e7acecd fix(acp): reject a mismatched fork cwd and resolve overlapping provider IDs (#53534) 2026-10-06 22:49:59 +05:30
Adam de2cacdac8 fix(docs): redirect inference help link 2026-10-06 11:18:52 -05:00
opencode-agent[bot] 6bffe7932e chore(core): refresh bundled models.dev snapshot 2026-10-06 12:21:22 +00:00
Victor Navarro ba281e7f91 docs(console): add workspace, providers, and api docs (#53531) 2026-10-06 14:11:33 +02:00
Shoubhit Dash f17b15eaa5 fix(acp): tighten cwd, cancel, delete, and terminal auth handling (#53359) 2026-10-06 17:15:51 +05:30
Shoubhit Dash f6ae986549 fix(acp): report tool names and full-file diffs (#53360) 2026-10-06 17:12:27 +05:30
Shoubhit Dash fb72e25c06 fix(acp): wait for a cold location's plugins before the first catalog read (#53358) 2026-10-06 17:10:48 +05:30
opencode-agent[bot] 184890e429 chore: update nix node_modules hashes 2026-10-06 11:23:46 +00:00
opencode-agent[bot]andBrendonovich a7523ac4b7 fix(ui): render dollar math with marked-katex-extension (#53513)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 19:13:35 +08:00
Brendan Allan 80c4a6b7c8 feat(app): polish mobile session navigation and drawers (#53267) 2026-10-06 17:12:44 +08:00
David Hill 592daf29f4 fix(app): refine project selector menu (#52349) 2026-10-06 09:01:34 +00:00
Luke Parker b1629294ec feat(desktop): redesign the browser bar (#53488) 2026-10-06 19:00:29 +10:00
opencode-agent[bot]andBrendonovich 8eb2ebd96c fix(app): disable submit hover on touch (#49461)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 08:08:53 +00:00
opencode-agent[bot]andBrendonovich 2eff7da1b6 fix(app): allow touch scrolling in directory picker (#51781)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 08:07:35 +00:00
opencode-agent[bot]andBrendonovich da20865bb9 fix(app): use HTTP-safe UUID for restored comment notes (#53501)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 07:53:42 +00:00
opencode-agent[bot] 6e4de805bb chore: update nix node_modules hashes 2026-10-06 06:00:33 +00:00
Kit Langton 50ae4bd4db chore: remove unused dependencies from V2 packages (#53482) 2026-10-05 22:51:11 -07:00
Jack 89a8f1a3db docs(go): document paid Space Bunny (#53487) 2026-10-06 13:28:36 +08:00
Kit Langton 81b92c043f feat(core): enable portable shell scanner on local and dev channels (#53479) 2026-10-05 22:13:16 -07:00
DmitriiandAiden Cline cabb5d035b fix(core): migrate the v1 thinking block-binding opt-out (#52327)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-06 00:06:58 -05:00
Kit Langton 2bddf58716 refactor(client): share the registered service decision between clients (#53241) 2026-10-05 21:35:29 -07:00
Max CoplanandOpenCode 8f0f4be03c fix(script): normalize local build channels (#53461)
Co-authored-by: OpenCode (GPT-5.6-Sol) <noreply@opencode.ai>
2026-10-05 23:03:27 -05:00
b78d10cd71 fix(server): always report opencode as mcp client name (#53471)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Eric Curtin <eric.curtin@docker.com>
2026-10-05 22:20:45 -05:00
opencode-agent[bot]andBrendonovich cb11287ae6 fix(app): use HTTP-safe UUID for attachment uploads (#51783)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-06 03:08:32 +00:00
9daf46b327 fix(session-ui): align provider error icon with first line (#52859)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-06 13:07:18 +10:00
opencode bd55d4895f sync release versions for v2.0.24 [skip ci] 2026-10-06 03:04:19 +00:00
opencode-agent[bot]andrekram1-node 7c59e7bfee chore(tui): remove unused plugin install keybinds (#53470)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-05 21:50:18 -05:00
opencode-agent[bot] 32a67d2d0a chore: update nix node_modules hashes 2026-10-06 02:33:42 +00:00
Vladimir Glafirov a1d2085af3 chore(core): bump gitlab-ai-provider to 6.19.0 (#53352) 2026-10-05 21:22:20 -05:00
Aiden Cline d318cf4125 fix(core): rename legacy OpenAI OAuth methods to Codex (#53467) 2026-10-05 21:21:42 -05:00
Vladimir Glafirov 5ae8a3c1d3 fix(core): resolve reasoning variants for GitLab Duo models (#51082) 2026-10-05 21:12:37 -05:00
Aiden Cline 209346a0fe fix(core): temporarily stop syncing w/ /models api for sign in w/ chatgpt due to openai bugs (#53466) 2026-10-05 21:05:11 -05:00
opencode-agent[bot]andLukeParkerDev 3ca0ac718b fix(session-ui): space errors and grouped updates in timeline (#52876)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-06 00:38:15 +00:00
Aiden Cline 48b295aef4 fix(core): clarify legacy ChatGPT OAuth method labels (#53451) 2026-10-05 18:56:17 -05:00
Luke Parker 19f8610c0a fix(app): commit a staged revert before switching the selection (#53445) 2026-10-06 09:40:44 +10:00
usrnk1andLukeParkerDev 754b144263 feat(desktop): clarify queued and pending steer messages (#53338)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-06 09:29:55 +10:00
opencode-agent[bot] 394a4fc64b chore: update nix node_modules hashes 2026-10-05 23:16:55 +00:00
706 changed files with 20566 additions and 4206 deletions

No files matched your search

+70 -67
View File
@@ -33,7 +33,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -55,7 +55,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.23",
"version": "2.0.24",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -179,7 +179,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -192,7 +192,6 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
@@ -205,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.23",
"version": "2.0.24",
"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.23",
"version": "2.0.24",
"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.23",
"version": "2.0.24",
"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.23",
"version": "2.0.24",
"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.23",
"version": "2.0.24",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -342,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/perplexity": "3.0.26",
@@ -369,13 +368,11 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.18.0",
"google-auth-library": "10.5.0",
"gitlab-ai-provider": "6.19.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jose": "6.0.11",
"jsonc-parser": "3.3.1",
@@ -409,7 +406,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -454,7 +451,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -491,7 +488,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -507,8 +504,12 @@
},
"packages/gui-extensions": {
"name": "@opencode/gui-extensions",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@betteroffice/docx": "0.4.3",
"@betteroffice/fonts": "0.4.0",
"@betteroffice/pptx": "0.2.0",
"@betteroffice/xlsx": "0.3.0",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/solid": "0.5.0",
@@ -552,7 +553,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -571,7 +572,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -584,7 +585,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -598,7 +599,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -613,7 +614,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -621,9 +622,7 @@
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@opencode/theme": "workspace:*",
@@ -639,8 +638,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": ">=0.5.14",
"@opentui/solid": ">=0.5.14",
"@opentui/core": ">=0.5.16",
"@opentui/solid": ">=0.5.16",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -652,7 +651,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -682,7 +681,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -697,7 +696,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -721,7 +720,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -742,7 +741,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -751,7 +750,6 @@
"@opencode/schema": "workspace:*",
"@opencode/simulation": "workspace:*",
"@opencode/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
@@ -764,7 +762,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -799,7 +797,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -819,7 +817,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -853,7 +851,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -872,7 +870,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -918,7 +916,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -932,7 +930,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -966,7 +964,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -975,6 +973,7 @@
"fuzzysort": "catalog:",
"katex": "0.16.47",
"marked": "catalog:",
"marked-katex-extension": "5.1.13",
"marked-shiki": "catalog:",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -1001,7 +1000,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1039,7 +1038,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1080,7 +1079,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.23",
"version": "2.0.24",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -1157,9 +1156,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.14",
"@opentui/keymap": "0.5.14",
"@opentui/solid": "0.5.14",
"@opentui/core": "0.5.16",
"@opentui/keymap": "0.5.16",
"@opentui/solid": "0.5.16",
"@pierre/diffs": "1.5.1",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -1639,6 +1638,14 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@betteroffice/docx": ["@betteroffice/docx@0.4.3", "", { "peerDependencies": { "@betteroffice/fonts": "^0" }, "optionalPeers": ["@betteroffice/fonts"] }, "sha512-CEErXJH3iCsi2a61gTNkAofLCzSTcktLmp710gGhMwbBfOqwQ2yI7dRFBV3ElGa9JxkS03IfejvLX0sAvlTrUQ=="],
"@betteroffice/fonts": ["@betteroffice/fonts@0.4.0", "", { "peerDependencies": { "@betteroffice/fonts-cjk": "^0" }, "optionalPeers": ["@betteroffice/fonts-cjk"] }, "sha512-x1BVwYDAgY5R6TWI4t9NLguSiAEbH5GItN2ZlpBIFoRXTIJgjBsXd6sUxPGpexXOyY6KdhRjG8XvHjyAYxEGCw=="],
"@betteroffice/pptx": ["@betteroffice/pptx@0.2.0", "", {}, "sha512-pNPQ58tlA+pVibERbLdQNskvP7a0GIMf4jhkCU/gPp8043Ichf6gB3PSb4ra9sfqoIaWqOaZKCWyptovA2+Q0A=="],
"@betteroffice/xlsx": ["@betteroffice/xlsx@0.3.0", "", {}, "sha512-K2bHtdk/epAxsmvxJlIWBRndjP3ltQU0Oh3p4gcZd2n5/YgXglo3SmMYX4nQe8WEm/yvz91p+aIasKyXXQP9Wg=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-iiIwlNoycOMUiaUzL1ZirLapCG3WY8cg9hSj4KgE/JYIWgqauoHikIlfUt7SdMqqGIkb4iZu6I1MxgDDJaRfBA=="],
@@ -2295,27 +2302,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
"@opentui/core": ["@opentui/core@0.5.16", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.16", "@opentui/core-darwin-x64": "0.5.16", "@opentui/core-linux-arm64": "0.5.16", "@opentui/core-linux-arm64-musl": "0.5.16", "@opentui/core-linux-x64": "0.5.16", "@opentui/core-linux-x64-musl": "0.5.16", "@opentui/core-win32-arm64": "0.5.16", "@opentui/core-win32-x64": "0.5.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-RDVip7Ih8Lg5tFkwTDGFqc0TXx8A+rRvm3UuDAqd9YayDx4fIsECBjSS8FDPLYvq5KP1gJB4oY8iGM8WgTbNqw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mbdp/A2KJOv7hI0b0ezqZx/5vh5G6LwhZnjRW4K3hNcoZM4yC55y/D+L8J960rT4o7QRB3cNXr3NSlnVi1btZA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-D1oA0VBitDHOYrg58GaxQQNAXJ1nSFwu2HQMZklL/LkrqZLX23K/oxgutH/GKvLsyRQNsIVfsXmGZ2LtGiGpxA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-ghu4tKIR3wZMZ8Ih/VLeyyG/C7kQgAZuKoGKRSUPcVEF6Qr99yHhtIixBrBoFXfqVm63n5lGimS6KKqbdUQ1yA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1vppHpf6iR7k+JF6kQtcLKr11gIxm2nH1h9y3eawrG4NCXDQbOvn7XtC6KsT3I1iy0A2Yy+nM0heISziORZlQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.16", "", { "os": "linux", "cpu": "x64" }, "sha512-y63M7JrjfAQPUwTXogDGqBjS46DfC51LebBUQYVE01/N2F1inybD08qEfxUAFx/Omlf7CXef6liLDdRyTLaohw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.16", "", { "os": "linux", "cpu": "x64" }, "sha512-lQ1kRwFPBRElG2uFl0afWzpHyUwVEizbV2Ue1IA8DXH15sRCOd7KqeOltpzuKJYIyLey7vLF8SofAe8K1JNnTg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-cML9+1tqEe+SaB5xlx9jKcYAIG15Eg9sImH/LYyMbm+al7dRBNy5IzdFCldXfwC50blM/goiyzLN1CjTo02vCQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1QZ9yHK9xTZBNXVgjPfbdQ94nfw0/KZic9XkuAsNBzUGTkYmfGKZSvPB1YKomBt/1lipRoCdMAnihlS/R7g5eg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.14", "", { "dependencies": { "@opentui/core": "0.5.14" }, "peerDependencies": { "@opentui/react": "0.5.14", "@opentui/solid": "0.5.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YGTAvRrpQTbRNV7GH0UxRuCSPxwnSooVdB+qPHHP+3ywc93+lhekljgP8dRivl32f3CiMqqm96Uj2PgwKxkC0Q=="],
"@opentui/keymap": ["@opentui/keymap@0.5.16", "", { "dependencies": { "@opentui/core": "0.5.16" }, "peerDependencies": { "@opentui/react": "0.5.16", "@opentui/solid": "0.5.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-W78sDuTHMjHa8NwYmoGx5Xy0ztcGsuzrUa/1d24jADj9JCYEJQjABnrWZBRSExsNjiFYV3SxeCKwAPhJli0M3g=="],
"@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="],
"@opentui/solid": ["@opentui/solid@0.5.16", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gCULWG+HORd5IVydiCchlmULgY1PQccODJvJKDNYJHDmhwyg9/sNSFUxoYajEXLZ40pUIL8ENAK+6p34Z9m/JQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -4153,7 +4160,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.18.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-dXTXkNt1SFCL7jGlqazHL6iUJEug21qq0tx2s+Tui7jWNUqIAHEzY9WY1+PnZlhxvB56jPPBcFAT0attpjnopg=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.19.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-ym+p6PxKvCKfBE3VKYTiacJwsmgq/DrOuGctpBnbIBekdnZT31RM6yTTNdzVS83K3+Ym4M+qNY34HEcTVM6EEQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4299,7 +4306,7 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
@@ -4613,6 +4620,8 @@
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
"marked-katex-extension": ["marked-katex-extension@5.1.13", "", { "peerDependencies": { "katex": ">=0.16 <0.19", "marked": ">=4 <19" } }, "sha512-+6gVygGg5at9g+YsuIQZk7XcAc6Pg1P0grEJKEWvF7DAwceZ7ZIvU0fhQe6auUqSbnPmSjJxZuICdSCdbGaFRw=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -6283,8 +6292,6 @@
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -6517,8 +6524,6 @@
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -7177,8 +7182,6 @@
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4AqU8dPEwo0ukoyEX1SuDVzsXQ7G/fIPnxoL400zH4M=",
"aarch64-linux": "sha256-p75sJYtR8a4oVKb1+Dgp8Kl+1onSmUw5NRv95bbV1+E=",
"aarch64-darwin": "sha256-Psyvbw/lGulysQlhK/MqcHVIwx2aCDTr+Pfr+gRWsG4="
"x86_64-linux": "sha256-GHLheOh+zY847BpZ9G/vCfjPT7jUt8doODFOQP9REjw=",
"aarch64-linux": "sha256-+To0HYoZzNJ5Qd7y22/ZvljcxQbchGBvYaCPYhte2As=",
"aarch64-darwin": "sha256-KVGWrTpe8SB8PgKAyb+2FFRBvXLkJlJVsVTHa/bUhg0="
}
}
+4 -4
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.23",
"version": "2.0.24",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -53,9 +53,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.14",
"@opentui/keymap": "0.5.14",
"@opentui/solid": "0.5.14",
"@opentui/core": "0.5.16",
"@opentui/keymap": "0.5.16",
"@opentui/solid": "0.5.16",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.24",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -285,7 +285,7 @@ const AnthropicThinkingEnabled = Schema.Struct({
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
type AnthropicThinking = typeof AnthropicThinking.Type
@@ -1001,18 +1001,21 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
// only reads `request.model.compatibility`.
const isThinkingOff = Schema.is(AnthropicThinkingDisabled)
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
const supportsEffortUpdates = (request: LLMRequest) => {
if (isThinkingOff(request.providerOptions?.thinking)) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
const version = claudeVersion(request.model.id)
if (version === undefined) return false
if (version.family === "opus" && version.major >= 5) return true
return version.major > 5 || (version.major === 5 && version.minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
if (thinking?.type === "disabled") return thinking
if (isThinkingOff(thinking)) return thinking
if (!supportsThinkingBlockBinding(model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
@@ -1607,7 +1610,7 @@ export const protocol = Protocol.make({
}),
step,
},
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
supportsEffortUpdates,
})
export const transport = <
@@ -1653,7 +1656,7 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
if (thinking && !isThinkingOff(thinking) && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
return betas
}
@@ -476,7 +476,9 @@ const MIN_THINKING_BUDGET = 1_024
const isThinkingDisabled = Schema.is(
Schema.Struct({
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
additionalModelRequestFields: Schema.Struct({
thinking: Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) }),
}),
}),
)
@@ -33,7 +33,7 @@ const Options = Schema.Struct({
store: lenient(Schema.Boolean),
thinkingLevel: lenient(ThinkingLevel),
thinkingSummaries: lenient(knownString<"auto" | "none">()),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
serviceTier: lenient(knownString<"standard" | "flex" | "priority" | "deferred">()),
})
export type OptionsInput = typeof Options.Encoded
export type ProviderOptionsInput = OptionsInput
@@ -507,6 +507,7 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
if (part.text.length === 0 && !encryptedContent) return undefined
return {
type: "reasoning",
...(id === undefined ? {} : { id }),
@@ -37,7 +37,8 @@ export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Creden
Effect.tryPromise({
try: async () => {
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
// ignoreCache re-reads shared config and SSO token files, so `aws sso login` takes effect without a restart.
const identity = await fromNodeProviderChain({ ignoreCache: true, profile: options.profile })()
return {
region: options.region,
accessKeyId: identity.accessKeyId,
@@ -9,7 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
// Mirrors OpenAI's `ServiceTier` union from the official SDK.
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale", "fast", "ultrafast"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
@@ -65,8 +65,8 @@ const messagesRoute = Route.make({
...AnthropicMessages.protocol,
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
supportsEffortUpdates: (request) => {
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
if (!(AnthropicMessages.protocol.supportsEffortUpdates?.(request) ?? false)) return false
if (request.model.compatibility?.supportsEffortUpdates !== undefined) return true
const version = claudeVersion(request.model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
},
+16
View File
@@ -268,6 +268,22 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("strips markers when thinking is disabled or between_tools", () =>
Effect.gen(function* () {
const sonnet = anthropic("claude-sonnet-5-5")
const betweenTools = yield* compileRequest(
LLM.request({
model: sonnet,
messages: conversation,
providerOptions: { thinking: { type: "between_tools" }, effort: "low" },
}),
)
expect(systemMessages(betweenTools.body)).toHaveLength(0)
expect(betweenTools.body.output_config).toEqual({ effort: "low" })
}),
)
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
Effect.gen(function* () {
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
@@ -9,6 +9,7 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "ultrafast" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
@@ -44,6 +44,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
Effect.gen(function* () {
for (const thinking of [
{ type: "disabled" },
{ type: "between_tools" },
{ type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } },
] as const) {
const request = LLM.request({
@@ -55,7 +56,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(compiled.body.thinking).toEqual(thinking)
expect(prepared.request.headers["anthropic-beta"]).toBe(
thinking.type === "disabled"
thinking.type === "disabled" || thinking.type === "between_tools"
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
)
@@ -325,11 +325,13 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through provider-defined service tiers", () =>
it.effect("passes through provider-defined and future service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
for (const serviceTier of ["scale", "ultrafast", "future-tier"]) {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier } }))
expect(prepared.body.service_tier).toBe("scale")
expect(prepared.body.service_tier).toBe(serviceTier)
}
}),
)
@@ -4011,6 +4013,46 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops empty reasoning items without summary or encrypted content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_empty_shell",
model,
messages: [
Message.user("What changed?"),
Message.assistant([
{
type: "reasoning",
text: "",
providerMetadata: {
openai: {
itemId: "rs_6aa28a10c05cf9f566f44022:rs_01a08aeb51217b92a5b853a0cb5b20ca",
reasoningEncryptedContent: null,
},
},
},
{ type: "text", text: "The parser changed." },
]),
Message.user("Summarize it."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ type: "message", role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "The parser changed." }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
])
}),
)
it.effect("assembles streamed function call input", () =>
Effect.gen(function* () {
const body = sseEvents(
+178 -18
View File
@@ -227,17 +227,17 @@ story("keeps the comment editor and its actions inside the page", async ({ page
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await expect(root.getByText("Preview your app, read docs, or browse the web.", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
@@ -245,9 +245,9 @@ story("shows the empty state over a blank native page and restores navigation",
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
@@ -255,12 +255,12 @@ story("keeps the submitted URL visible until the browser reports navigation", as
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
@@ -274,7 +274,7 @@ story("keeps the submitted URL visible until the browser reports navigation", as
story("restores the current URL each time the same submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
for (const submission of [1, 2]) {
await story.step(`blocked submission ${submission}`, async () => {
@@ -282,13 +282,28 @@ story("restores the current URL each time the same submitted navigation is block
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByRole("alert")).toHaveText("Request failed")
await expect(root.getByRole("alert")).toHaveText("Only web pages can open here.")
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
}
})
story("explains why a typed address cannot open instead of searching for it", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
for (const [typed, reason] of [
["ftp://example.com", "Only web pages can open here."],
["https://user:pass@example.com", "Addresses with a user name or password can't open here."],
]) {
await address.fill(typed)
await address.press("Enter")
await expect(root.getByRole("alert")).toHaveText(reason)
await expect(address).toHaveValue("https://alpha.example/")
}
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
@@ -296,7 +311,7 @@ story("shows a themed failure state for only the failed tab and allows retry", a
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
await expect(root.getByRole("combobox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
@@ -317,7 +332,7 @@ story("returns a failed tab to the empty state when an empty URL is submitted",
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
@@ -326,14 +341,14 @@ story("returns a failed tab to the empty state when an empty URL is submitted",
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
@@ -352,10 +367,55 @@ story("selects the full URL when the address field gains focus", async ({ page }
.toBe(true)
})
story("draws only the address at rest when focus leaves the window with the field selected", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.click()
// Chromium blurs the field when the window or the native page takes focus, and the field keeps its full selection.
await address.evaluate((input: HTMLInputElement) => {
input.dispatchEvent(new FocusEvent("blur"))
input.select()
})
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await page.mouse.move(0, 0)
// Computed ::selection styles do not report the default highlight, so compare the input's own paint with the
// selection collapsed. The drawn address is hidden: a selection layer can change how its text is antialiased.
await page.addStyleTag({ content: '[data-slot="browser-address-display"] { visibility: hidden }' })
const field = root.locator('[data-component="browser-address"]')
const selected = (await field.screenshot()).toString("base64")
await address.evaluate((input: HTMLInputElement) => input.setSelectionRange(0, 0))
const collapsed = (await field.screenshot()).toString("base64")
const changed = await page.evaluate(
async (shots) => {
const pixels = await Promise.all(
shots.map(async (data) => {
const image = new Image()
image.src = `data:image/png;base64,${data}`
await image.decode()
const canvas = new OffscreenCanvas(image.width, image.height)
canvas.getContext("2d")?.drawImage(image, 0, 0)
return canvas.getContext("2d")?.getImageData(0, 0, image.width, image.height).data ?? new Uint8ClampedArray()
}),
)
return Array.from({ length: (pixels[0]?.length ?? 0) / 4 }, (_, index) => index * 4).filter((offset) =>
[0, 1, 2].some((channel) => pixels[0]?.[offset + channel] !== pixels[1]?.[offset + channel]),
).length
},
[selected, collapsed],
)
expect(changed).toBe(0)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
@@ -371,12 +431,112 @@ story("keeps the current page visible while a submitted URL loads", async ({ pag
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("suggests visited pages and opens the first one that completes the typed address", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Seed history", exact: true }).click()
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.click()
await address.pressSequentially("localhost")
const options = page.getByRole("option")
await expect(options).toHaveCount(1)
await expect(options.first()).toHaveAttribute("aria-selected", "true")
// Arrow keys that pick IME candidates leave the suggestions alone.
await address.dispatchEvent("keydown", { key: "ArrowDown", isComposing: true })
await expect(options.first()).toHaveAttribute("aria-selected", "true")
await address.press("Enter")
await expect(address).toHaveValue("http://localhost:5173/settings")
// A search query also opens the first match; stepping past the list leaves the typed text, which searches.
await address.click()
await address.pressSequentially("color format")
await expect(options).toHaveCount(1)
await expect(options.first()).toHaveAttribute("aria-selected", "true")
await address.press("ArrowDown")
await expect(options.first()).toHaveAttribute("aria-selected", "false")
await address.press("Enter")
await expect(address).toHaveValue("https://www.google.com/search?q=color%20format")
})
story("lists recent pages on a blank tab and opens one", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await root.getByRole("button", { name: "Seed history", exact: true }).click()
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Recent", { exact: true })).toBeVisible()
// The globe at the field's start belongs to the field: a click there focuses it.
await expect(address).not.toBeFocused()
await root.locator('[data-component="browser-address"]').click({ position: { x: 16, y: 14 } })
await expect(address).toBeFocused()
await address.blur()
await root.getByRole("button", { name: /^Settings - Preview/ }).click()
await expect(address).toHaveValue("http://localhost:5173/settings")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
// The opened page owns the field afterwards: an edit that is never submitted shows its URL again.
await address.click()
await address.fill("unsubmitted")
await address.blur()
await expect(address).toHaveValue("http://localhost:5173/settings")
})
story("adds the whole page to the session and keeps the draft when the button is pressed again", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const field = root.locator('[data-component="browser-address"]')
const add = root.getByRole("button", { name: "Add page to session", exact: true })
await field.hover()
await add.click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await editor.fill("Check the header layout")
await field.hover()
await add.click()
await expect(editor).toHaveValue("Check the header layout")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments").getByRole("listitem")).toHaveText([/: Check the header layout$/])
})
story("focuses the address field when the page uses the address shortcut", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Address shortcut", exact: true }).click()
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await expect(address).toBeFocused()
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
})
story("shows the page's cookies in the site information and clears them", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Site information", exact: true }).click()
const site = page.getByRole("dialog")
await expect(site).toContainText("3 cookies in use")
await site.getByRole("button", { name: "Clear", exact: true }).click()
await expect(site).toContainText("0 cookies in use")
})
story("zooms the page from the browser options in view and keeps its zoom in the address field", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const options = root.getByRole("button", { name: "Browser options", exact: true })
await options.click()
await page.getByRole("menuitem", { name: /^Zoom in/ }).click()
await expect(root.getByText("Zoom: 110", { exact: true })).toBeVisible()
// The menu covered the page with a still; it closes so the zoomed page shows at once.
await expect(page.getByRole("menu")).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
const zoom = root.getByRole("button", { name: "110%", exact: true })
await expect(zoom).toBeVisible()
await options.click()
await expect(page.getByRole("menuitem", { name: /^Reset zoom/ })).toContainText("110%")
await page.keyboard.press("Escape")
await zoom.click()
await expect(root.getByText("Zoom: 100", { exact: true })).toBeVisible()
await expect(zoom).toBeHidden()
})
@@ -95,7 +95,14 @@ for (const direction of ["ltr", "rtl"]) {
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
).toBeLessThan(1)
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
// Below the mobile breakpoint the variant picker opens in a bottom drawer instead of a menu.
await expect(
width < 768
? page
.getByRole("dialog", { name: "Choose model variant", exact: true })
.getByRole("button", { name: "high", exact: true })
: page.getByRole("menuitemradio", { name: "high", exact: true }),
).toBeVisible()
await page.keyboard.press("Escape")
await expect(variant).toBeFocused()
await expect(submit).toBeInViewport()
@@ -85,7 +85,7 @@ const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
const servers: Servers = { list: () => [] }
const servers: Servers = { list: () => [], get: () => undefined }
const workspaces: Workspaces = { on: () => () => undefined }
@@ -348,6 +348,7 @@ function standIn(overrides: Partial<Interface>): Interface {
keybind: () => [],
matches: () => false,
servers: () => ["local"],
server: () => undefined,
...overrides,
}
}
@@ -38,6 +38,16 @@ test("home commands do not wait for session search", async ({ page }) => {
release.resolve()
})
test("lists recent sessions before a query is entered", async ({ page }) => {
const { dialog } = await openCommandPalette(page, true)
const session = dialog.getByRole("option", { name: /Palette fixture session/ })
await expect(dialog.getByText("Recent sessions", { exact: true })).toBeVisible()
await expect(session).toHaveAttribute("aria-selected", "false")
await session.click()
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
@@ -213,7 +213,18 @@ test("keeps a narrow session composer contained when invoking a built-in", async
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
const dialog = page.getByRole("dialog", { name: "Select model", exact: true })
const search = dialog.getByRole("searchbox", { name: "Search models", exact: true })
await expect(search).toBeFocused()
await expect(editor).toHaveText("keep me")
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await editor.fill("/model")
await expect(page.locator('[data-suggestion-id="model.choose"]')).toHaveAttribute("data-active", "")
await editor.press("Enter")
await expect(search).toBeFocused()
await expect(editor).toBeEmpty()
})
test("lists slash commands in their built-in order", async ({ page }) => {
+81 -1
View File
@@ -1,6 +1,8 @@
import { expect, test, type Page } from "@playwright/test"
import { holdRoute, seed, sessionHref } from "../utils/app"
import { holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockRemoteServer } from "../utils/workspace"
import { expectAppVisible } from "../utils/waits"
test.use({ serviceWorkers: "block" })
@@ -85,8 +87,11 @@ test("Home shows loaded sessions before the location request resolves", async ({
test("Home and the directory picker load without newer browser APIs", async ({ page }) => {
await page.addInitScript(() => {
// Safari 16.6 has none of these APIs. Remove them before the web entry runs.
// SAFETY: `Partial` only makes the static method optional so `delete` type-checks; the target is the real global.
delete (Map as Partial<typeof Map>).groupBy
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).withResolvers
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).try
})
await openHome(page, { fileList: () => [] })
@@ -101,6 +106,81 @@ test("Home and the directory picker load without newer browser APIs", async ({ p
await expect(target).toBeVisible()
})
test("adding a project to a signed-out server re-pairs it, then continues at the paired address", async ({ page }) => {
// The saved address rejects the new token, so saving moves the server to the link's address.
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { name: "Remote", password: "old-password" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()
await remote.getByRole("button", { name: "Add project", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(`${paired}/auth/connect/one-time-code`)
await editor.getByRole("button", { name: "Save", exact: true }).click()
// The folder picker reads the paired address; the removed one would reject the request.
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})
test("a server whose saved password changes works with the new password", async ({ page }) => {
const accepted = { password: "old-password" }
await mockOpenCodeServer(page, {
server: REMOTE_SERVER,
directory: "/remote/project",
project: project({ id: "proj_remote", directory: "/remote/project" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: () => accepted.password,
})
// Saved with the password the server accepts until the test changes it.
await seed(page, {
storage: {
"opencode.global.dat:server": {
list: [{ type: "http", displayName: "Remote", http: { url: REMOTE_SERVER, password: "old-password" } }],
},
},
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
// Use the server once, so its controller exists with the old password.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await picker.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(picker).toHaveCount(0)
await remote.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Edit", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByPlaceholder("password").fill("new-password")
// The server now rejects the old password, as after `opencode service set password`.
accepted.password = "new-password"
await editor.getByRole("button", { name: "Save", exact: true }).click()
await expect(editor).toHaveCount(0)
// The picker reads the folder through the server's controller, which must use the saved password now.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})
const recovery = "C:/OpenCode/Worktrees/project-menu-recovery"
const worktree =
@@ -0,0 +1,79 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const { width, direction } of [
{ width: 390, direction: "ltr" },
{ width: 390, direction: "rtl" },
{ width: 1280, direction: "ltr" },
] as const) {
test(`submitting a prompt ${width < 768 ? "unfocuses" : "keeps focus on"} the composer at ${width}px in ${direction}`, async ({
page,
}) => {
await openSubmitSession(page, width, direction)
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await editor.fill("Please check focus after sending.")
await expect(editor).toBeFocused()
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
)
await editor.press("Enter")
expect((await admitted).ok()).toBe(true)
await expect(editor).toBeEmpty()
if (width < 768) {
await expect(editor).not.toBeFocused()
return
}
await expect(editor).toBeFocused()
})
}
test.describe("touch submission", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test("tapping Send unfocuses the composer", async ({ page }) => {
await openSubmitSession(page, 390, "ltr")
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await editor.fill("Please dismiss the keyboard after sending.")
await expect(editor).toBeFocused()
const admitted = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
)
await page.getByRole("button", { name: "Send", exact: true }).tap()
expect((await admitted).ok()).toBe(true)
await expect(editor).toBeEmpty()
await expect(editor).not.toBeFocused()
})
})
async function openSubmitSession(page: Page, width: number, direction: "ltr" | "rtl") {
await page.setViewportSize({ width, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
})
await openWithDirection(page, sessionHref(fixture.targetID), direction)
}
@@ -0,0 +1,208 @@
import { expect, test } from "@playwright/test"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const scheme of ["light", "dark"] as const) {
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile extensions reuse the session drawer in ${scheme} ${direction}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript((scheme) => {
localStorage.setItem("opencode-color-scheme", scheme)
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
}, scheme)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await openWithDirection(page, sessionHref(fixture.targetID), direction)
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await more.click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
for (const [name, empty] of [
["MCP", "No MCP servers configured"],
["Plugins", "No plugins configured"],
["Skills", "No skills configured"],
["LSP", "No LSP servers configured"],
]) {
const trigger = details.getByRole("button", { name, exact: true })
await trigger.click()
const extension = page.getByRole("dialog", { name, exact: true })
await expect(extension.getByText(empty, { exact: true })).toBeVisible()
await expect(extension).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(extension.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(extension.locator(".session-service-menu")).toHaveCount(0)
await expect(extension).toBeInViewport({ ratio: 1 })
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(trigger).toBeFocused()
}
await details.getByRole("button", { name: "Skills", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Skills", exact: true }).getByText("No skills configured", { exact: true }),
).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("extensions.png") })
await testInfo.attach("extensions", { path: testInfo.outputPath("extensions.png"), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(more).toBeFocused()
await more.click()
await expect(
page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Files", exact: true }),
).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("mobile extension drawers keep configured lists, retry, refresh, and MCP toggles working", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
const state = { fail: true, extra: false, enabled: true }
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{
id: "drawer-plugin",
source: { type: "package", target: "drawer-plugin" },
features: {},
state: { status: "active" },
},
...(state.extra
? [
{
id: "extra-plugin",
source: { type: "package", target: "extra-plugin" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "drawer-skill", name: "drawer-skill", path: "/skills/drawer/SKILL.md", content: "Drawer skill" },
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({ json: [{ type: "document", info: { lsp: { "drawer-lsp": { command: ["drawer-lsp"] } } } }] }),
)
await page.route(
(url) => url.pathname.startsWith("/api/mcp") || url.pathname.startsWith("/api/experimental/mcp"),
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const path = new URL(route.request().url()).pathname
if (path.endsWith("/disconnect")) {
state.enabled = false
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
path === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `mcp-${String(index).padStart(2, "0")}`,
status: { status: index === 0 && !state.enabled ? "disabled" : "connected" },
})),
},
})
},
)
await openWithDirection(page, sessionHref(fixture.targetID), "ltr")
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await details.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("drawer-plugin", { exact: true })).toBeVisible()
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
state.extra = true
await details.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("extra-plugin", { exact: true })).toBeVisible()
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
for (const [name, item] of [
["Skills", "drawer-skill"],
["LSP", "drawer-lsp"],
]) {
await details.getByRole("button", { name, exact: true }).click()
const extension = page.getByRole("dialog", { name, exact: true })
await expect(extension.getByText(item, { exact: true })).toBeVisible()
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
}
await details.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(mcp.getByRole("switch")).toHaveCount(30)
await expect
.poll(() =>
mcp
.locator('[data-slot="mobile-panel-content"]')
.evaluate((element) => element.scrollHeight > element.clientHeight),
)
.toBe(true)
await expect(mcp).toBeInViewport({ ratio: 1 })
const disconnect = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/disconnect"))
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).focus()
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).press("Space")
await disconnect
await expect(mcp.getByRole("switch", { name: "mcp-00", exact: true })).not.toBeChecked()
const last = mcp
.locator(".session-mcp-row")
.filter({ has: page.getByRole("switch", { name: "mcp-29", exact: true }) })
await last.scrollIntoViewIfNeeded()
await expect(last).toBeInViewport()
await mcp.getByRole("switch", { name: "mcp-29", exact: true }).focus()
await expect(mcp.getByRole("switch", { name: "mcp-29", exact: true })).toBeFocused()
})
@@ -1,10 +1,21 @@
import { expect, test, type Request } from "@playwright/test"
import { expect, test, type Page, type Request } from "@playwright/test"
import { sessionHref } from "../utils/app"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { fileNode } from "../utils/workspace"
test.use({ viewport: { width: 390, height: 844 } })
async function openFiles(page: Page) {
await page
.getByRole("tablist", { name: "Session view", exact: true })
.getByRole("tab", { name: "More...", exact: true })
.click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Files", exact: true })
.click()
}
test("mobile files browse, comment, search, and close tabs", async ({ page }) => {
await mockStressTimeline(page, {
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
@@ -14,7 +25,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
})
await page.goto(sessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
@@ -50,7 +61,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
.click()
await expect(session).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
const filter = files.getByRole("combobox", { name: "Filter files", exact: true })
await expect(filter).toBeHidden()
@@ -63,7 +74,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await openFiles(page)
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
@@ -239,7 +250,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(navigation.getByRole("tab", { name: "More...", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(selected).toHaveText([file])
await expect(files).toHaveAttribute("data-browsing", "false")
@@ -248,20 +259,23 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
}
})
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses by backdrop, Escape, and drag", async ({ page }) => {
await mockStressTimeline(page)
await page.goto(sessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.getByRole("tablist", { name: "Session view", exact: true })
.getByRole("tab", { name: "More...", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag"] as const) {
for (const dismissal of ["backdrop", "escape", "drag"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "More options", exact: true })
.getByRole("button", { name: "Session details", exact: true })
.click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
@@ -270,8 +284,6 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
@@ -0,0 +1,224 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
for (const direction of ["ltr", "rtl"] as const) {
for (const paid of [true, false]) {
test(`mobile model and variant pickers use drawers with ${paid ? "paid" : "free"} models in ${direction}`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await openPickerSession(page, direction, paid)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await expect(variant).toHaveText("balanced")
await variant.click()
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
await expect(variants.getByRole("button", { name: "balanced", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
)
await expect(variants.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(variants).toBeInViewport({ ratio: 1 })
await expect(variants).not.toHaveAttribute("data-transitioning")
await variants.screenshot({ path: testInfo.outputPath("variants.png") })
await page.keyboard.press("Escape")
await expect(variants).toBeHidden()
await expect(variant).toBeFocused()
await variant.click()
await variants.getByRole("button", { name: "high", exact: true }).click()
await expect(variants).toBeHidden()
await expect(variant).toHaveText("high")
await expect(variant).toBeFocused()
const model = composer.locator('[data-action="composer-model"]')
await expect(model).toHaveText("Mobile Model A")
await expect(model).toHaveAttribute("data-control-type", "drawer")
await model.click()
const models = page.getByRole("dialog", { name: "Select model", exact: true })
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
await expect(search).not.toBeFocused()
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveAttribute("aria-pressed", "true")
await expect(models.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(models).toBeInViewport({ ratio: 1 })
await expect(models).not.toHaveAttribute("data-transitioning")
await expect
.poll(() =>
models.evaluate((element) => {
const drawer = element.getBoundingClientRect()
const search = element.querySelector('[data-component="text-input-v2"]')!.getBoundingClientRect()
const row = element.querySelector('[data-component="settings-row"]')!.getBoundingClientRect()
const searchClip = element.querySelector('[data-slot="mobile-panel-content"]')!.getBoundingClientRect()
const rowClip = element.querySelector(".settings-models")!.getBoundingClientRect()
return {
searchStart: Math.round(search.left - drawer.left),
searchEnd: Math.round(drawer.right - search.right),
rowStart: Math.round(row.left - drawer.left),
rowEnd: Math.round(drawer.right - row.right),
searchClipStart: Math.round(search.left - searchClip.left),
searchClipEnd: Math.round(searchClip.right - search.right),
rowClipStart: Math.round(row.left - rowClip.left),
rowClipEnd: Math.round(rowClip.right - row.right),
}
}),
)
.toEqual({
searchStart: 12,
searchEnd: 12,
rowStart: 12,
rowEnd: 12,
searchClipStart: 12,
searchClipEnd: 12,
rowClipStart: 12,
rowClipEnd: 12,
})
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-inline-start", "12px")
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-block-start", "16px")
await models.screenshot({ path: testInfo.outputPath("models.png") })
await search.click()
await expect(search).toBeFocused()
await search.fill("no-matching-model")
await expect(models.getByText("No model results", { exact: true })).toBeVisible()
await search.fill("Mobile Model B")
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCount(0)
await search.press("Enter")
await expect(models).toBeHidden()
await expect(model).toHaveText("Mobile Model B")
await expect(composer.getByRole("textbox", { name: "Prompt", exact: true })).toBeFocused()
await model.click()
await expect(search).toHaveValue("")
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
await expect(models.getByRole("button", { name: /Mobile Model B/ })).toHaveAttribute("aria-pressed", "true")
await expect
.poll(() => models.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(models).not.toHaveAttribute("data-transitioning")
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(models).toBeHidden()
await expect(model).toBeFocused()
if (paid) {
await model.click()
await models.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(models).toBeHidden()
await expect(page.getByRole("dialog", { name: "Manage models", exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(1)
return
}
await model.click()
await models.getByRole("button", { name: "Connect provider", exact: true }).click()
await expect(models).toBeHidden()
const providers = page.getByRole("dialog", { name: "Connect provider", exact: true })
await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(1)
})
}
test(`desktop model and variant pickers retain menus in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await openPickerSession(page, direction, true)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await variant.click()
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
await expect(variant).toHaveText("high")
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
const model = composer.locator('[data-action="composer-model"]')
await expect(model).toHaveAttribute("data-control-type", "popover")
await model.click()
await expect(page.getByPlaceholder("Search models", { exact: true })).toBeFocused()
await page.getByRole("menuitemradio", { name: /Mobile Model B/ }).click()
await expect(model).toHaveText("Mobile Model B")
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
})
}
test.describe("touch composer pickers", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test("model and default variant open with one tap", async ({ page }) => {
await openPickerSession(page, "ltr", true)
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
const composer = page.locator('[data-component="composer"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await variant.tap()
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
await variants.getByRole("button", { name: "default", exact: true }).tap()
await expect(variants).toBeHidden()
await expect(variant).toHaveText("default")
await page.touchscreen.tap(5, 300)
await expect(variant).toHaveCSS("pointer-events", "auto")
await expect(variant).toHaveCSS("opacity", "1")
await variant.tap()
await expect(variants).toBeVisible()
await expect
.poll(() => variants.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await page.locator('[data-slot="mobile-drawer-overlay"]').tap({ position: { x: 10, y: 10 } })
await expect(variants).toBeHidden()
await composer.locator('[data-action="composer-model"]').tap()
const models = page.getByRole("dialog", { name: "Select model", exact: true })
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
await expect(search).not.toBeFocused()
await search.tap()
await expect(search).toBeFocused()
})
})
async function openPickerSession(page: Page, direction: "ltr" | "rtl", paid: boolean) {
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) => ({
...session,
model: { id: "mobile-a", providerID: "opencode", variant: "balanced" },
})),
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: Object.fromEntries(
["a", "b"].map((id) => [
`mobile-${id}`,
{
id: `mobile-${id}`,
name: `Mobile Model ${id.toUpperCase()}`,
cost: { input: paid ? 1 : 0, output: paid ? 1 : 0 },
limit: { context: 200_000 },
variants: { balanced: {}, high: {} },
},
]),
),
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "mobile-a" },
},
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page)
await page.addInitScript((direction) => {
localStorage.setItem("opencode-color-scheme", direction === "rtl" ? "dark" : "light")
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
user: ["a", "b"].map((id) => ({ providerID: "opencode", modelID: `mobile-${id}`, visibility: "show" })),
recent: [],
variant: {},
}),
)
}, direction)
await openWithDirection(page, sessionHref(fixture.targetID), direction)
await expectAppVisible(page.getByRole("textbox", { name: "Prompt", exact: true }))
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const scheme of ["light", "dark"] as const) {
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile session options are compact and readable in ${scheme} ${direction}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript((scheme) => {
localStorage.setItem("opencode-color-scheme", scheme)
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
}, scheme)
await openWithDirection(page, sessionHref(fixture.targetID), direction)
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
await more.click()
const drawer = page.getByRole("dialog", { name: "More options", exact: true })
await expect(drawer).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(drawer.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(drawer.getByRole("heading", { name: "More options", exact: true })).toHaveCSS("width", "1px")
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", scheme)
await expect
.poll(() =>
drawer.evaluate((element) => {
const options = element.querySelector('[data-slot="session-mobile-view-options"]')!
return getComputedStyle(options).backgroundColor !== getComputedStyle(element).backgroundColor
}),
)
.toBe(true)
await drawer.screenshot({ path: testInfo.outputPath("options.png") })
await testInfo.attach("options", { path: testInfo.outputPath("options.png"), contentType: "image/png" })
for (const name of ["Files", "Terminal", "Usage", "Session details"]) {
const option = drawer.getByRole("button", { name, exact: true })
await expect(option).toHaveCSS("height", "44px")
await expect
.poll(() =>
option.evaluate((element) => {
const context = document.createElement("canvas").getContext("2d")!
function luminance(color: string) {
context.clearRect(0, 0, 1, 1)
context.fillStyle = color
context.fillRect(0, 0, 1, 1)
const channels = Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((channel) => {
const value = channel / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
}
const text = luminance(getComputedStyle(element).color)
const surface = luminance(getComputedStyle(element.parentElement!).backgroundColor)
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05)
}),
)
.toBeGreaterThanOrEqual(4.5)
}
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await expect(more).toBeFocused()
for (const dismissal of ["backdrop", "drag"] as const) {
await more.click()
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "backdrop")
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
if (dismissal === "drag") {
const handle = await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox()
expect(handle).not.toBeNull()
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2)
await page.mouse.down()
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2 + 1)
await page.mouse.move(handle!.x + handle!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer).toBeHidden()
await expect(more).toBeFocused()
}
})
}
}
@@ -28,7 +28,8 @@ for (const position of ["top", "bottom"] as const) {
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
@@ -37,32 +38,39 @@ for (const position of ["top", "bottom"] as const) {
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(navigation).toHaveCount(1)
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "More..."])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
if (position === "top") {
const titlebar = page.locator('[data-slot="titlebar-v2"]')
await expect(titlebar).toHaveCSS("padding-top", "16px")
await expect(titlebar).toHaveCSS("height", "44px")
await expect(titlebar).toHaveCSS("padding-top", "8px")
await expect(titlebar).toHaveCSS("height", "36px")
}
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
return !!bounds && bounds.x >= 1 && bounds.x <= 2 && bounds.width >= 386 && bounds.width <= 388
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const dock = await page.locator('[data-component="session-composer-dock"]').boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
if (!bar || !input || !dock || !panel) return false
if (position === "bottom")
return bar.y >= dock.y + dock.height && Math.abs(bar.y + bar.height - panel.y - panel.height) <= 1
return Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
@@ -82,8 +90,21 @@ for (const position of ["top", "bottom"] as const) {
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(views).toBeVisible()
await expect(more).toHaveAttribute("aria-expanded", "true")
for (const name of ["Files", "Terminal", "Usage", "Session details"])
await expect(views.getByRole("button", { name, exact: true })).toBeVisible()
await expect(views).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(views).toBeHidden()
await expect(more).toHaveAttribute("aria-expanded", "false")
await expect(more).toBeFocused()
await expect(picker).toHaveText("Session")
await more.press("Enter")
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
@@ -92,24 +113,32 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(views.getByRole("button", { name: "Usage", exact: true })).toHaveAttribute("aria-pressed", "true")
await expect(views.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await views.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
await views.getByRole("button", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(details.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(details.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(details.getByRole("heading", { name: "Session details", exact: true })).toHaveCSS("width", "1px")
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
@@ -118,18 +147,23 @@ for (const position of ["top", "bottom"] as const) {
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
await more.click()
await expect(views.getByRole("button", { name: "Files", exact: true })).toHaveAttribute("aria-pressed", "true")
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
@@ -146,14 +180,14 @@ for (const position of ["top", "bottom"] as const) {
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(picker).toHaveText("More...")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(picker).toHaveText("More...")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
@@ -174,18 +208,20 @@ for (const position of ["top", "bottom"] as const) {
}
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTab(fixture.expected.targetTitle)
await expect(picker).toHaveText("Session")
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTabs()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
@@ -220,15 +256,15 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
await page.setViewportSize({ width: 390, height: 844 })
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
await more.click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Changes")
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(
page.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { name: "README.md", exact: true }),
).toHaveAttribute("aria-selected", "true")
@@ -244,7 +280,8 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
await page.reload()
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.setViewportSize({ width: 390, height: 844 })
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("button", { name: "README.md", exact: true }).click()
await expect(files.getByText("contents:README.md", { exact: true })).toBeVisible()
@@ -268,14 +305,17 @@ test("a narrow-screen palette pick keeps the view and dock, and the side region
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const terminal = page.locator("#terminal-panel")
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const views = page.getByRole("dialog", { name: "More options", exact: true })
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
await expect(terminal.locator("textarea")).toBeEditable()
await page.keyboard.press("ControlOrMeta+p")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox").fill("README")
await dialog.getByRole("option", { name: "/ README.md", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Terminal")
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("More...")
await expect(terminal).toHaveAttribute("data-opened", "true")
await page.setViewportSize({ width: 1280, height: 900 })
@@ -859,6 +859,45 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(pending).toContainText(followUp)
await expect(thinking).toHaveCount(0)
const bubble = pending.locator('[data-slot="user-message-text"]')
const colors =
delivery === "steer"
? {
delivered: await userRow(page, userID)
.locator('[data-slot="user-message-text"]')
.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
text: getComputedStyle(element).color,
})),
pending: await bubble.evaluate((element) => {
const probe = document.createElement("span")
probe.style.backgroundColor = "var(--v2-background-bg-layer-02)"
probe.style.color = "var(--v2-text-text-base)"
element.appendChild(probe)
const colors = {
background: getComputedStyle(probe).backgroundColor,
text: getComputedStyle(probe).color,
}
probe.remove()
return colors
}),
}
: undefined
if (colors) {
await expect(bubble).toHaveCSS("background-color", colors.pending.background)
await expect(bubble).toHaveCSS("color", colors.pending.text)
await pending.hover()
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(
/^Pending\s*·\s*Build\s*·\s*Queue Model$/,
)
await bubble.evaluate((element) => (element.dataset.deliveryMarker = "pending"))
}
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", {
sessionID,
@@ -921,6 +960,18 @@ for (const delivery of ["steer", "queue"] as const) {
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveCount(0)
await expect(pending).toHaveCount(1)
if (colors) {
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Build\s*·\s*Queue Model$/)
await expect(bubble).toHaveCSS("background-color", colors.delivered.background)
await expect(bubble).toHaveCSS("color", colors.delivered.text)
await expect(bubble).toHaveCSS("transition-property", "background-color, color")
await transcript.screenshot({ path: testInfo.outputPath("delivered-steer.png") })
await page.emulateMedia({ reducedMotion: "reduce" })
await expect(bubble).toHaveCSS("transition-duration", "0s")
}
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
"data-message-id",
+83 -3
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
const directory = "C:/Projects/settings-demo"
@@ -463,7 +464,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
worktrees: () => inventory,
onWorktreeRemove: (body) => {
inventory.splice(
inventory.findIndex((item) => item.directory === (body as { directory: string }).directory),
inventory.findIndex((item) => item.directory === body.directory),
1,
)
},
@@ -499,6 +500,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
// Home never listed this session, so only the new worktree's directory read can show it.
view.sessions.push(session({ id: "ses_discovered", directory: discovered, title: "Discovered session", projectID }))
inventory.push({ directory: discovered, strategy: "git" })
// SAFETY: a worktree.updated event carries only the project ID, which is all the app reads from it.
await view.push([
{ id: "evt_settings_worktree_updated", created: Date.now(), type: "worktree.updated", data: { projectID } },
] as OpenCodeEvent[])
@@ -582,7 +584,9 @@ test.describe("worktrees prefetch", () => {
test("server Worktrees hover only prefetches metadata", async ({ page }) => {
const { settings } = await open(page)
const calls = { projects: 0, worktrees: [] as string[], refreshes: [] as string[] }
const calls = { projects: 0, worktrees: new Array<string>(), refreshes: new Array<string>() }
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/worktree/refresh")
calls.refreshes.push(request.postDataJSON().projectID)
@@ -754,7 +758,9 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
await expect(editor.getByLabel("Pairing link", { exact: true })).toBeFocused()
await expect(editor.getByPlaceholder("password")).toHaveCount(0)
await editor.getByRole("button", { name: "Use password", exact: true }).click()
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
const name = editor.getByPlaceholder("Localhost", { exact: true })
await name.click()
@@ -766,6 +772,80 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await expect(settings).toBeVisible()
})
test("the add server dialog pairs from a one-time link and explains a spent one", async ({ page }) => {
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
const { settings } = await open(page)
const link = `${paired}/auth/connect/one-time-code`
const addServer = async () => {
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(link)
await editor.getByRole("button", { name: "Add server", exact: true }).click()
return editor
}
const added = await addServer()
await expect(added).toBeHidden()
await expect(settings.getByText(paired, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to settings" }).click()
const spent = await addServer()
await expect(spent.getByRole("alert")).toHaveText(
"This pairing link expired or was already used. Run opencode pair to get a new one.",
)
})
test("a pairing code with several addresses keeps the first one that works with its token", async ({ page }) => {
// Both addresses reach one server. The second spends the code first, so the first answers 401, and the token works on
// it as on every address of that server.
const first = "http://127.0.0.1:4098"
const second = "http://127.0.0.1:4099"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
for (const [server, pairing] of [
[first, { code: "spent-by-the-other-address", token: "session-token" }],
[second, { code: "one-time-code", token: "session-token" }],
] as const) {
await mockOpenCodeServer(page, {
server,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing,
})
}
const { settings } = await open(page)
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor
.getByLabel("Pairing link", { exact: true })
.fill(JSON.stringify({ code: "one-time-code", urls: [first, second] }))
await editor.getByRole("button", { name: "Add server", exact: true }).click()
await expect(editor).toBeHidden()
await expect(settings.getByText(first, { exact: true })).toBeVisible()
await expect(settings.getByText(second, { exact: true })).toHaveCount(0)
})
test("the tab layout preference switches to vertical tabs and survives reload", async ({ page }) => {
const { settings } = await open(page)
const layout = settings.locator('[data-action="settings-tab-layout"]')
@@ -0,0 +1,95 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, installStressSessionTabs, mockStressTimeline } from "../utils/session-fixture"
import { sessionHref, draftHref } from "../utils/app"
import { openWithDirection } from "../utils/direction"
for (const direction of ["ltr", "rtl"] as const) {
test(`bottom mobile navigation adds no extra top panel gutter in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
})
await openWithDirection(page, "/", direction)
const panel = page.locator('[data-slot="home-panel"]')
await expect(panel).toHaveCSS("--shell-top-inset", "0px")
await expect
.poll(async () => {
const titlebar = await page.locator('[data-slot="titlebar-v2"]').boundingBox()
const main = await page.getByRole("main").boundingBox()
return titlebar && main && titlebar.y >= main.y + main.height
})
.toBe(true)
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && bounds.y - main.y
})
.toBe(0)
})
for (const screen of [
{ name: "home", href: () => "/", panel: (page: Page) => page.locator('[data-slot="home-panel"]') },
{
name: "new-session",
href: () => draftHref("shell-spacing-draft"),
panel: (page: Page) => page.locator('[data-component="new-session"]'),
},
{
name: "session",
href: () => sessionHref(fixture.targetID),
panel: (page: Page) => page.locator('[data-slot="session-chat-panel"]'),
},
{ name: "settings", href: () => "/settings", panel: (page: Page) => page.getByTestId("settings-screen") },
]) {
test(`${screen.name} keeps an 8px top gutter and only inline border clearance on mobile in ${direction}`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
if (screen.name === "new-session") await installStressSessionTabs(page, { draftID: "shell-spacing-draft" })
await openWithDirection(page, screen.href(), direction)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
const panel = screen.panel(page)
await expect(panel).toBeVisible()
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && bounds.y - main.y
})
.toBe(8)
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
})
.toEqual({ x: 1, width: 2 })
// The existing desktop gutter must return at the responsive breakpoint.
await page.setViewportSize({ width: 768, height: 900 })
await expect(panel).toHaveCSS("--shell-inline-inset", "8px")
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
if (screen.name === "session") return
await expect
.poll(async () => {
const bounds = await panel.boundingBox()
const main = await page.getByRole("main").boundingBox()
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
})
.toEqual({ x: 8, width: 16 })
})
}
}
@@ -106,6 +106,37 @@ for (const mount of ["cached", "explicit"] as const) {
})
}
test("reconnects the same terminal after a transient connection failure", async ({ page }) => {
const { pty } = await openSession(page, workspace)
let tickets = 0
await page.route(/\/api\/pty\/[^/]+\/connect-token/, (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
tickets += 1
if (tickets !== 2) return route.fallback()
return route.fulfill({
status: 503,
headers: { "access-control-allow-origin": "*" },
body: "Service Unavailable",
})
})
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => pty.sockets.length).toBe(1)
const id = pty.sockets[0]!.id
await terminal.evaluate((element) => element.setAttribute("data-connection-probe", "original"))
await pty.sockets[0]!.close(1011, "Temporary disconnection")
await expect.poll(() => tickets).toBe(3)
await expect.poll(() => pty.sockets.length).toBe(2)
expect(pty.sockets[1]!.id).toBe(id)
expect(pty.rejected).toEqual([])
await expect(terminal).toHaveAttribute("data-connection-probe", "original")
expect(pty.created.map((item) => item.id)).toEqual([id])
})
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const { editor } = await openSession(page, workspace)
const terminal = page.locator('[data-component="terminal"]')
+26 -18
View File
@@ -1,13 +1,14 @@
import { expect, test, type Page } from "@playwright/test"
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { createServer, type ServerResponse } from "node:http"
import type { AddressInfo } from "node:net"
import { once } from "node:events"
import { createHash } from "node:crypto"
import { join, extname, relative, sep } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { build } from "vite"
import { serviceWorker } from "../../vite.pwa"
import { onDemandAsset, serviceWorker } from "../../vite.pwa"
type Site = {
url: string
@@ -127,14 +128,14 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
const file = builds[state.version][path]
const types: Record<string, string> = {
".js": "text/javascript",
".html": "text/html",
".json": "application/json",
".wasm": "application/wasm",
}
const types = new Map([
[".js", "text/javascript"],
[".html", "text/html"],
[".json", "application/json"],
[".wasm", "application/wasm"],
])
response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream")
response.setHeader("content-type", types.get(extname(path)) ?? "application/octet-stream")
if (file) return void response.end(file)
@@ -145,9 +146,8 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
try {
await use({
@@ -284,9 +284,11 @@ fixture(
await expect
.poll(() =>
replacement.evaluate(() => {
const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration
// This runs in the service worker, whose global scope holds its registration.
const registration =
"registration" in self && self.registration instanceof ServiceWorkerRegistration ? self.registration : undefined
return { waiting: !!registration.waiting, active: registration.active?.state }
return { waiting: !!registration?.waiting, active: registration?.active?.state }
}),
)
.toEqual({ waiting: false, active: "activated" })
@@ -378,15 +380,22 @@ fixture("does not substitute cached HTML for API or missing asset navigations",
expect(await asset?.text()).toBe("Not found")
})
test("the production build precaches every deployable file", async ({ page, context }) => {
test("the production build precaches every deployable file except on-demand Office assets", async ({
page,
context,
}) => {
const directory = new URL("../../dist/", import.meta.url)
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
const deployable = (await readdir(directory, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
const files = deployable.filter((path) => !onDemandAsset(path))
expect(files.length).toBeGreaterThan(1)
// The Office engines ship but load on demand, so a build without them would make this exclusion vacuous.
expect(deployable.some((path) => path.endsWith(".wasm") && onDemandAsset(path))).toBe(true)
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
@@ -411,9 +420,8 @@ test("the production build precaches every deployable file", async ({ page, cont
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
const url = `http://127.0.0.1:${address.port}`
try {
@@ -56,7 +56,6 @@ export function mount(input: { server: string; route: string; direction: Directi
return (
<AppInterface
servers={[server]}
defaultServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)}
router={(props) => <MemoryRouter {...props} history={history} />}
>
+12 -4
View File
@@ -63,6 +63,12 @@ export class MockShellNotFound extends Schema.TaggedError<MockShellNotFound>()("
message: Schema.String,
}) {}
// The server's error for an unknown PTY, or one owned by another workspace.
export class MockPtyNotFound extends Schema.TaggedError<MockPtyNotFound>()("PtyNotFoundError", {
ptyID: Schema.String,
message: Schema.String,
}) {}
// The server's error for a request without its password.
export class MockUnauthorized extends Schema.TaggedError<MockUnauthorized>()("UnauthorizedError", {
message: Schema.String,
@@ -75,6 +81,8 @@ export class MockUnsupported extends Schema.TaggedError<MockUnsupported>()("Mock
const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501))
const PtyMissing = [MockNotFound.pipe(HttpApiSchema.status(404)), MockPtyNotFound.pipe(HttpApiSchema.status(404))]
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
@@ -242,7 +250,7 @@ const Group = HttpApiGroup.make("mock")
HttpApiEndpoint.get("ptyGet", "/api/pty/:ptyID", {
params: PtyParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
@@ -250,21 +258,21 @@ const Group = HttpApiGroup.make("mock")
params: PtyParams,
payload: Pty.UpdateInput,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
HttpApiEndpoint.delete("ptyRemove", "/api/pty/:ptyID", {
params: PtyParams,
success: NoContent,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
HttpApiEndpoint.post("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: PtyParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
error: PtyMissing,
}),
)
.add(
+38 -2
View File
@@ -11,6 +11,7 @@ import {
MockBadRequest,
MockInternal,
MockNotFound,
MockPtyNotFound,
MockShellNotFound,
MockUnauthorized,
MockUnsupported,
@@ -135,6 +136,8 @@ export interface MockServerConfig {
strictDirectory?: boolean
// Answers 401 UnauthorizedError unless a request carries this password; a function may change it mid-test.
password?: Resolvable<string>
// Serves GET /auth/connect/:code: `code` redeems once for `token` (send it as the password); others answer 401.
pairing?: { code: string; token: string }
}
export type MockPtyInfo = {
@@ -147,7 +150,14 @@ export type MockPtyInfo = {
pid: number
}
export type MockPtySocket = { id: string; url: URL; input: string[]; closed: boolean; send(data: string): void }
export type MockPtySocket = {
id: string
url: URL
input: string[]
closed: boolean
send(data: string): void
close(code: number, reason: string): Promise<void>
}
export type MockPty = {
list: MockPtyInfo[]
@@ -401,6 +411,29 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
})
if (config.pairing) {
const pairing = { ...config.pairing, redeemed: false }
await page.route(`${server}/auth/connect/*`, (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: corsHeaders })
const code = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1) ?? "")
if (code !== pairing.code || pairing.redeemed) {
return route.fulfill({
status: 401,
headers: corsHeaders,
json: Schema.encodeSync(MockUnauthorized)(
new MockUnauthorized({ message: "Pairing link expired or already used" }),
),
})
}
pairing.redeemed = true
return route.fulfill({ headers: corsHeaders, json: { token: pairing.token } })
})
}
if (config.pty) {
const host = new URL(server).host
await page.routeWebSocket(
@@ -422,6 +455,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
input: [],
closed: false,
send: (data) => ws.send(data),
close: (code, reason) => ws.close({ code, reason }),
}
ws.onMessage((message) => socket.input.push(message.toString()))
@@ -672,7 +706,9 @@ function mockHandlers(
const directory = requestDirectory(config, request)
const found = state.pty.find(id, directory)
return found ? Effect.succeed(found) : Effect.fail(new MockNotFound({ message: "PTY not found" }))
return found
? Effect.succeed(found)
: Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY not found: ${id}` }))
}),
),
)
@@ -241,11 +241,7 @@ export function mount(input: {
return (
<Show when={extensions.ready()}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
<AppInterface servers={servers()} router={(props) => <MemoryRouter {...props} history={history} />} />
</Show>
)
}
@@ -4,7 +4,6 @@ import { render } from "solid-js/web"
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
import { ServerConnection } from "../../src/runtime/server/registry"
// The app on a Windows desktop platform, so the titlebar shows the app menu. Menu actions are listed in an output.
export function mount(input: { server: string }) {
@@ -59,7 +58,6 @@ export function mount(input: { server: string }) {
<output aria-label="Desktop menu actions">{store.actions.join(",")}</output>
<AppInterface
servers={[{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } }]}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</AppBaseProviders>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.24",
"description": "",
"type": "module",
"exports": {
+1 -6
View File
@@ -86,7 +86,6 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
@@ -108,11 +107,7 @@ export function AppInterface(props: {
)
return (
<ServersProvider
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<ServersProvider canonicalLocalServer={props.canonicalLocalServer} servers={props.servers}>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
+26 -6
View File
@@ -56,19 +56,39 @@ export type ComposerSession = {
clear: (messageID: string) => void
}
api: {
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
switchAgent: (input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0]) => Promise<unknown>
switchModel: (input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0]) => Promise<unknown>
command: (
input: Parameters<ServerSDK["api"]["session"]["command"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["command"]>
shell: (
input: Parameters<ServerSDK["api"]["session"]["shell"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["shell"]>
switchAgent: (
input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["switchAgent"]>
switchModel: (
input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["switchModel"]>
revert: {
commit: (
input: Parameters<ServerSDK["api"]["session"]["revert"]["commit"]>[0],
) => ReturnType<ServerSDK["api"]["session"]["revert"]["commit"]>
}
}
data: {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => ReturnType<Data["session"]["prompt"]>
setStatus: Data["session"]["setStatus"]
}
}
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
current: Accessor<
| {
agent?: string
model?: { id: string; providerID: string; variant?: string }
revert?: { messageID: string }
}
| undefined
>
admitted: (messageID: string) => boolean
}
@@ -2,6 +2,7 @@ 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 { uuid } from "@/runtime/persistence/uuid"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
@@ -29,7 +30,7 @@ export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
// 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}`)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${uuid()}/${file.name}`)
return write(url, file, server.conn.http.password, report, signal)
},
@@ -50,6 +51,7 @@ function write(url: URL, file: File, password: string | undefined, report: (load
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}`))
// SAFETY: a 200 from `fs.write` is its declared success body, `Location.response(FileSystem.Write)`.
resolve((xhr.response as { data: { path: string } }).data.path)
})
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
+2 -2
View File
@@ -1,5 +1,6 @@
import { useCommand, type CommandOption } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { DialogSelectModel } from "@/providers/models/select-dialog"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
@@ -33,7 +34,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
void dialog.show(() => <DialogConnectProvider directory={workspace().directory} />)
}
const chooseModel = async () => {
const chooseModel = () => {
const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
const selection = window.getSelection()
@@ -54,7 +55,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
})
}
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
})
+39 -110
View File
@@ -1,123 +1,52 @@
import type { SessionMessageUser } from "@opencode/client/promise"
import { Option, Schema } from "effect"
import type { FileSelection } from "@/workspaces/files/model"
import { Persistence } from "@/runtime/persistence/schema"
import { uuid } from "@/runtime/persistence/uuid"
import { FileSelection } from "@/workspaces/files/types"
import { durableNote, LegacyBrowserNote, NoteComment, type ContextItem } from "./schema"
export type PromptFileComment = {
type?: "file"
path: string
selection?: FileSelection
comment: string
preview?: string
origin?: "review" | "file"
}
export const PromptFileComment = Persistence.struct({
type: Schema.optional(Schema.Literal("file")),
path: Schema.String,
selection: Persistence.optional(FileSelection),
comment: Schema.String,
preview: Persistence.optional(Schema.String),
origin: Persistence.optional(Schema.Literals(["review", "file"])),
})
export type PromptFileComment = typeof PromptFileComment.Type
export type PromptComment = PromptFileComment | NoteComment
const decodeNoteComment = Schema.decodeUnknownOption(NoteComment)
const decodeLegacyBrowserNote = Schema.decodeUnknownOption(LegacyBrowserNote)
/** An attachment the model receives as a path on the server rather than inline bytes. */
export type PromptAttachmentReference = {
name: string
mime: string
path: string
}
export const PromptAttachmentReference = Persistence.struct({
name: Schema.String,
mime: Schema.String,
path: Schema.String,
})
function selection(selection: unknown) {
if (!selection || typeof selection !== "object") return undefined
const startLine = Number((selection as FileSelection).startLine)
const startChar = Number((selection as FileSelection).startChar)
const endLine = Number((selection as FileSelection).endLine)
const endChar = Number((selection as FileSelection).endChar)
export type PromptAttachmentReference = typeof PromptAttachmentReference.Type
if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
// Presentation metadata needs a comments list, which prompts from other clients lack; a malformed comment or
// attachment is skipped rather than discarding the rest.
const decodePromptPresentation = Schema.decodeUnknownOption(
Schema.Struct({
displayText: Schema.String,
comments: Schema.Array(Schema.Unknown),
attachments: Persistence.array(PromptAttachmentReference),
}),
)
return {
startLine,
startChar,
endLine,
endChar,
} satisfies FileSelection
}
const decodePromptComment = Schema.decodeUnknownOption(Schema.Union([NoteComment, LegacyBrowserNote, PromptFileComment]))
export function createCommentMetadata(input: PromptFileComment) {
return {
opencodeComment: {
path: input.path,
selection: input.selection,
comment: input.comment,
preview: input.preview,
origin: input.origin,
},
}
}
export function readCommentMetadata(value: unknown) {
if (!value || typeof value !== "object") return
const meta = (value as { opencodeComment?: unknown }).opencodeComment
if (!meta || typeof meta !== "object") return
const path = (meta as { path?: unknown }).path
const comment = (meta as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return
const preview = (meta as { preview?: unknown }).preview
const origin = (meta as { origin?: unknown }).origin
return {
path,
selection: selection((meta as { selection?: unknown }).selection),
comment,
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
} satisfies PromptComment
}
export function readPromptPresentation(value: unknown) {
if (!value || typeof value !== "object") return
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 []
if ((item as { type?: unknown }).type === "note") return Option.toArray(decodeNoteComment(item))
if ((item as { type?: unknown }).type === "browser") return Option.toArray(decodeLegacyBrowserNote(item))
const path = (item as { path?: unknown }).path
const comment = (item as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return []
const preview = (item as { preview?: unknown }).preview
const origin = (item as { origin?: unknown }).origin
return [
{
path,
comment,
selection: selection((item as { selection?: unknown }).selection),
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
},
]
}),
}
export function readPromptPresentation(metadata: SessionMessageUser["metadata"]) {
return Option.getOrUndefined(
Option.map(decodePromptPresentation(metadata), (presentation) => ({
displayText: presentation.displayText,
attachments: presentation.attachments,
comments: presentation.comments.flatMap((item): PromptComment[] => Option.toArray(decodePromptComment(item))),
})),
)
}
export function formatAttachmentReference(input: PromptAttachmentReference) {
@@ -132,7 +61,7 @@ export function formatNoteComment(input: NoteComment) {
/** Restores a sent comment to the composer, for example after a revert or fork. */
export function commentContextItem(comment: PromptComment): ContextItem {
// The message may predate this app process, so a note's live references can no longer be trusted.
if (comment.type === "note") return { ...durableNote(comment), commentID: crypto.randomUUID() }
if (comment.type === "note") return { ...durableNote(comment), commentID: uuid() }
return {
type: "file",
+6 -2
View File
@@ -1,4 +1,5 @@
import { Show, createMemo } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
@@ -66,6 +67,7 @@ function ComposerModelControl(props: {
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const mobile = createMediaQuery("(max-width: 767px)")
const content = () => (
<>
@@ -87,6 +89,7 @@ function ComposerModelControl(props: {
return (
<Show when={!props.loading}>
<Tooltip
inactive={mobile()}
placement="top"
gutter={4}
value={
@@ -97,7 +100,7 @@ function ComposerModelControl(props: {
}
>
<Show
when={props.paid}
when={props.paid || mobile()}
fallback={
<Button
data-action="composer-model"
@@ -115,6 +118,7 @@ function ComposerModelControl(props: {
>
<ModelSelectorPopover
model={props.model}
unpaid={!props.paid}
trigger={(triggerProps) => (
<Button
{...triggerProps}
@@ -124,7 +128,7 @@ function ComposerModelControl(props: {
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="composer-model"
data-control-type="popover"
data-control-type={mobile() ? "drawer" : "popover"}
>
{content()}
</Button>
+5 -3
View File
@@ -25,9 +25,11 @@
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
@media (hover: hover) {
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
+112 -25
View File
@@ -8,10 +8,12 @@ import {
onMount,
Show,
Suspense,
lazy,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -46,6 +48,12 @@ import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
export type {
ComposerAttachment,
ComposerComment,
@@ -350,6 +358,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
mobileDrawer
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
@@ -804,6 +813,7 @@ export function ComposerEditorAddMenu(props: {
}
function ComposerEditorConfiguredSelect(props: {
mobileDrawer?: boolean
title: string
keybind?: string[]
control: ComposerSelectControl
@@ -815,6 +825,7 @@ function ComposerEditorConfiguredSelect(props: {
return (
<ComposerEditorSelect
mobileDrawer={props.mobileDrawer}
title={props.title}
class={props.class}
keybind={props.control.keybind?.() ?? props.keybind}
@@ -831,6 +842,7 @@ function ComposerEditorConfiguredSelect(props: {
}
export function ComposerEditorSelect(props: {
mobileDrawer?: boolean
title: string
keybind?: string[]
options: ComposerOption[]
@@ -840,8 +852,36 @@ export function ComposerEditorSelect(props: {
onOpenChange?: (open: boolean) => void
onSelect: (id: string) => void
}) {
const mobile = createMediaQuery("(max-width: 767px)")
const [store, setStore] = createStore({ open: false, loaded: false })
let trigger: HTMLButtonElement | undefined
const setOpen = (open: boolean) => {
setStore("open", open)
if (open) setStore("loaded", true)
props.onOpenChange?.(open)
}
createEffect(() => {
if (!mobile() && store.open) setOpen(false)
})
const content = () => (
<>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ms-0.5 -me-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Tooltip
inactive={props.mobileDrawer && mobile()}
placement="top"
value={
<>
@@ -850,36 +890,83 @@ export function ComposerEditorSelect(props: {
</>
}
>
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={Button}
<Show
when={props.mobileDrawer && mobile()}
fallback={
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<Menu.Trigger
as={Button}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
aria-label={props.title}
>
{content()}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
}
>
<Button
ref={(element: HTMLButtonElement) => {
trigger = element
}}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
aria-label={props.title}
aria-haspopup="dialog"
aria-expanded={store.open}
onClick={() => setOpen(true)}
>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ms-0.5 -me-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
{content()}
</Button>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
hideHeader
title={props.title}
open={store.open}
onOpenChange={setOpen}
returnFocus={() => trigger}
>
<div class="flex flex-col overflow-hidden rounded-lg border border-v2-border-border-base bg-v2-background-bg-layer-02 divide-y divide-v2-border-border-base">
<For each={props.options}>
{(option) => (
<Button
variant="ghost"
class="w-full !h-10 !justify-start !rounded-none !px-3 !font-[440] capitalize focus-visible:!outline-offset-[-2px]"
aria-pressed={props.current === option.id}
data-state={props.current === option.id ? "pressed" : undefined}
onClick={() => {
props.onSelect(option.id)
setOpen(false)
}}
>
{option.label}
<Show when={props.current === option.id}>
<Icon name="check" class="ms-auto" />
</Show>
</Button>
)}
</For>
</div>
</MobilePanelDrawer>
</Suspense>
</Show>
</Show>
</Tooltip>
)
}
@@ -204,7 +204,7 @@ export function createComposerEditor(input: {
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
if (event.type === "popover.select") {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (!action) result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(draft.state.prompt.filter(isAttachment), 0)
+20
View File
@@ -130,6 +130,11 @@ function session(input: {
input.calls.push("shell")
}),
command: input.command ?? (async () => undefined),
revert: {
commit: async () => {
input.calls.push("revert-commit")
},
},
},
data: {
location: { command: { list: () => [] } },
@@ -138,6 +143,16 @@ function session(input: {
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt?.(value)
// The admitted inbox item, as the server returns it.
return {
id: value.id ?? "msg_admitted",
sessionID: value.sessionID,
time: { created: 0 },
type: "user" as const,
payload: { text: value.text },
delivery: value.delivery ?? "steer",
}
},
},
},
@@ -183,6 +198,11 @@ describe("Composer submission", () => {
current: { agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } },
calls: ["switch-model"],
},
// A prompt commits a staged revert, which would delete the model switch made after its boundary.
{
current: { agent: "plan", model: { id: "old", providerID: "old" }, revert: { messageID: "msg_reverted" } },
calls: ["switch-agent", "revert-commit", "switch-model"],
},
])("applies the selection before sending one captured value: $calls", async (row) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
+12 -2
View File
@@ -332,6 +332,8 @@ function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<type
submission.target().mode.set("normal")
input.setMode("normal")
input.closePopover()
if (window.matchMedia("(max-width: 767px)").matches) input.editor()?.blur()
}
function restoreSubmission(
@@ -455,6 +457,7 @@ async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
beforeModel?: () => Promise<void>,
) {
const cancel = track?.(session.id, selection)
@@ -465,6 +468,8 @@ async function applySelection(
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
await beforeModel?.()
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
@@ -489,10 +494,15 @@ async function sendPrompt(
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
await applySelection(session, value.selection, track)
// Like the TUI, a staged revert settles after the agent switch and before the model switch and admission. The
// server would otherwise commit it on admission and delete every row from its boundary on, the model switch too.
const settle = async () => {
if (session.current()?.revert) await session.api.revert.commit({ sessionID: session.id })
}
if (value.delivery === "steer") await applySelection(session, value.selection, track, settle)
else await settle()
const admission = {
id: value.id,
sessionID: session.id,
+1 -2
View File
@@ -16,7 +16,7 @@ import pkg from "../package.json"
import { ServerConnection } from "@/runtime/server/registry"
const getLocale = () => {
if (typeof navigator !== "object") return "en" as const
if (typeof navigator === "undefined") return "en" as const
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
@@ -101,7 +101,6 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
servers={server ? [server] : []}
>
+23 -14
View File
@@ -31,7 +31,6 @@ export function createHomeProjectsController(home: HomeController) {
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = ServerConnection.authenticate
const revealProject = useRevealProject()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
@@ -41,6 +40,23 @@ export function createHomeProjectsController(home: HomeController) {
{ initialValue: _state },
)
function edit(conn: ServerConnection.Http, onSave?: (saved: ServerConnection.Http) => void) {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} onSave={(saved) => onSave?.(saved)} />)
})
}
// An expired pairing session or a changed password signs the app out of an HTTP server; signing in again edits it,
// and the action that asked for sign-in continues once the dialog saves, as it does for extension servers. It continues
// with the saved connection: the one it started with still carries the rejected credentials.
function authenticate(conn: ServerConnection.Any, onConnected?: (conn: ServerConnection.Any) => void) {
if (conn.type !== "http" || !home.server.health(conn)?.unauthorized)
return ServerConnection.authenticate(conn, () => onConnected?.(conn))
edit(conn, onConnected)
return true
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
@@ -70,22 +86,14 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
edit: (conn: ServerConnection.Http) => edit(conn),
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
focus: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => home.selection.focusServer(conn))) return
if (authenticate(conn, (next) => home.selection.focusServer(next))) return
home.selection.focusServer(conn)
},
},
@@ -94,12 +102,12 @@ export function createHomeProjectsController(home: HomeController) {
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.select(conn, directory))) return
if (authenticate(conn, (next) => home.project.select(next, directory))) return
home.project.select(conn, directory)
},
add: home.project.add,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
if (authenticate(conn, (next) => home.project.openProjectNewSession(next, directory))) return
home.project.openProjectNewSession(conn, directory)
},
canImportSession: !!platform.openAttachmentPickerDialog,
@@ -119,6 +127,7 @@ export function createHomeProjectsController(home: HomeController) {
const api = home.server.context(conn).sdk.api.session
// SAFETY: the payload is SessionTransfer.Data's own encoding, which the import endpoint decodes and validates.
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
@@ -152,7 +161,7 @@ export function createHomeProjectsController(home: HomeController) {
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => choose(conn))) return
if (authenticate(conn, (next) => choose(next))) return
if (home.server.health(conn)?.healthy === false) return
choose(conn)
@@ -19,8 +19,6 @@ export function HomeProjects(props: {
serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault()}
defaultServerKey={props.projects.server.defaultKey()}
canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
@@ -29,7 +27,6 @@ export function HomeProjects(props: {
onAuthenticateServer={props.projects.server.authenticate}
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
canHideServer={props.projects.server.canHide}
+11 -14
View File
@@ -33,6 +33,11 @@ const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whi
const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
// Extension servers ask for sign-in themselves; an HTTP server that rejects its saved credentials needs a new
// pairing link or password.
const signInRequired = (server: ServerConnection.Any, health: ServerHealth | undefined) =>
(server.type === "extension" && server.authenticationRequired) || (server.type === "http" && !!health?.unauthorized)
const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
`project:${ServerConnection.key(server)}:${directory}`
@@ -47,8 +52,6 @@ export type HomeProjectsViewProps = {
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
canDefaultServer: boolean
defaultServerKey: ServerConnection.Key | null | undefined
canRevealProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
@@ -57,7 +60,6 @@ export type HomeProjectsViewProps = {
onAuthenticateServer?: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
canHideServer: (server: ServerConnection.Any) => boolean
@@ -158,7 +160,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
}
function HomeProjectsPanel(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const [contextMenu, setContextMenu] = createStore<{ open: string | undefined }>({ open: undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
@@ -216,7 +218,9 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
when={
props.servers.length > 1 ||
props.servers.some(
(server) => server.type === "extension" && (server.authenticationRequired || server.connecting),
(server) =>
signInRequired(server, props.serverHealth(server)) ||
(server.type === "extension" && server.connecting),
)
}
fallback={
@@ -253,7 +257,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
const authentication = () => item.type === "extension" && item.authenticationRequired
const authentication = () => signInRequired(item, props.serverHealth(item))
const connecting = () => item.type === "extension" && item.connecting
return (
@@ -340,12 +344,9 @@ function HomeServerRow(props: {
language: HomeProjectsViewProps["language"]
projectsForServer: HomeProjectsViewProps["projectsForServer"]
contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"]
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
defaultServerKey: HomeProjectsViewProps["defaultServerKey"]
onFocusServer: HomeProjectsViewProps["onFocusServer"]
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
canHideServer: HomeProjectsViewProps["canHideServer"]
@@ -358,7 +359,7 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const authentication = () => props.server.type === "extension" && props.server.authenticationRequired
const authentication = () => signInRequired(props.server, props.health)
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
@@ -463,13 +464,9 @@ function HomeServerRow(props: {
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
canHide={props.canHideServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
onHide={() => props.onHideServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
+2 -1
View File
@@ -20,8 +20,9 @@ export function Home() {
return (
<div
data-slot="home-panel"
class={`
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
mx-[var(--shell-inline-inset,8px)] mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
@@ -8,6 +8,7 @@ import {
createCommandPaletteCommandEntry,
createServerSessionEntries,
type CommandPaletteEntry,
type PaletteHighlight,
} from "@/shell/commands/palette"
import { CommandPaletteView, matchesCommandPaletteEntry } from "@/shell/commands/dialog"
@@ -20,7 +21,7 @@ export function HomeCommandPalette(props: {
const global = useGlobal()
const language = useLanguage()
const server = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const state: PaletteHighlight = { cleanup: undefined, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
@@ -36,6 +37,7 @@ export function HomeCommandPalette(props: {
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
recentCategory: () => language.t("palette.group.recentSessions"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
@@ -1,3 +1,4 @@
import { Predicate } from "effect"
import { base64Encode } from "@opencode/util/encode"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Session } from "@opencode/schema/session"
@@ -47,7 +48,10 @@ export function createNewSessionComposerAdapter(props: {
async start(selection, submission, message) {
const draftID = props.draftID
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const projectDirectory =
data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
@@ -163,6 +167,7 @@ export function createNewSessionComposerAdapter(props: {
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
revert: { commit: (input) => afterCreation(() => serverSDK.api.session.revert.commit(input)) },
},
data: {
location: data.location,
@@ -239,16 +244,16 @@ async function resolveSessionDirectory(input: {
})
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
return error.message
}
function errorMessage(language: ReturnType<typeof useLanguage>, cause: unknown) {
if (Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)) return cause.message
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (
Predicate.hasProperty(cause, "data") &&
Predicate.hasProperty(cause.data, "message") &&
Predicate.isString(cause.data.message) &&
cause.data.message
)
return cause.data.message
return language.t("common.requestFailed")
}
@@ -341,7 +341,7 @@ export function PromptProjectSelector(props: {
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => props.controller.setSearchRef(el)}
@@ -402,16 +402,16 @@ export function PromptProjectSelector(props: {
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
<Icon name="circle-xmark" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<div class="my-0.5 max-h-[224px] overflow-y-auto">
<Show
when={props.controller.servers().length > 1}
fallback={
@@ -451,8 +451,8 @@ export function PromptProjectSelector(props: {
</Show>
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col">
<div class="-mx-0.5 h-px bg-v2-border-border-muted" />
<div class="flex flex-col pt-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
+1 -1
View File
@@ -103,7 +103,7 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@ export function NewSessionView(props: {
<PromptProjectAddButton controller={props.project} />
</Show>
<Show when={props.project.selected()}>
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center justify-center gap-0 text-v2-text-text-faint">
<PromptProjectSelector controller={props.project} placement="bottom" />
<Show
when={props.workspace.bar.visible()}
@@ -327,7 +327,7 @@ export function PromptWorkspaceSelector(props: {
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
}}
>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
@@ -355,7 +355,7 @@ export function PromptWorkspaceSelector(props: {
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
@@ -363,7 +363,7 @@ export function PromptWorkspaceSelector(props: {
}}
aria-label={language.t("common.clear")}
>
<Icon name="close-small" size="small" />
<Icon name="circle-xmark" />
</button>
</Show>
</div>
@@ -7,14 +7,23 @@ import type {
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { formatServerError } from "@/runtime/server/errors"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" | "external" }>
type Authorization = IntegrationOauthConnectOutput["data"]
type Polling = {
generation: number
timer?: ReturnType<typeof setTimeout>
disposed: boolean
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt?: Authorization
}
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
// Console sign-in is the connection method for both providers.
export const CONSOLE_INTEGRATION = "opencode"
@@ -35,7 +44,7 @@ export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
if (actual === undefined) return false
const equal = Array.isArray(actual)
? typeof condition.value === "string" && actual.includes(condition.value)
? actual.some((item) => item === condition.value)
: actual === condition.value
return condition.op === "eq" ? equal : !equal
@@ -43,7 +52,7 @@ export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
if (!active) return answer
return { ...answer, [field.key]: field.default }
return Object.assign(answer, { [field.key]: field.default })
}, {})
}
@@ -74,9 +83,8 @@ export function createProviderConnectionController(options: {
// Not createResource: the dialog is owned by whichever page opened it, so reading a pending
// resource here would suspend that page's <Suspense> and blank the screen behind the dialog.
const [integration, setIntegration] = createStore({
const [integration, setIntegration] = createStore<{ loading: boolean; latest?: IntegrationInfo }>({
loading: true,
latest: undefined as IntegrationInfo | undefined,
})
createEffect(
@@ -100,7 +108,8 @@ export function createProviderConnectionController(options: {
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
(method): method is ProviderConnectMethod =>
method.type === "key" || method.type === "oauth" || method.type === "external",
)
if (values?.length) return [...values]
@@ -108,14 +117,19 @@ export function createProviderConnectionController(options: {
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
})
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
const [store, setStore] = createStore<{
methodIndex?: number
authorization?: Authorization
formAnswer?: FormAnswer
// Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial
// value would keep multi-method providers on the spinner instead of the method list.
state: undefined as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
state?: "pending" | "waiting" | "refreshing" | "ready" | "error" | "form"
error?: string
auto: boolean
connected: boolean
browserFailed: boolean
statusFailed: boolean
}>({
auto: false,
// The credential is stored; a retry only needs to reload the catalogs.
connected: false,
@@ -124,12 +138,9 @@ export function createProviderConnectionController(options: {
statusFailed: false,
})
const polling = {
const polling: Polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt: undefined as Authorization | undefined,
}
const currentMethod = createMemo(() =>
@@ -206,8 +217,6 @@ export function createProviderConnectionController(options: {
)
}
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error))
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
@@ -276,7 +285,9 @@ export function createProviderConnectionController(options: {
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: isConsole() ? language.t("provider.connect.console.statusFailed") : errorMessage(result.error),
error: isConsole()
? language.t("provider.connect.console.statusFailed")
: formatServerError(result.error, language.t),
})
return
@@ -358,8 +369,6 @@ export function createProviderConnectionController(options: {
return
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: language.t("provider.connect.error.unsupportedFields") })
@@ -368,11 +377,35 @@ export function createProviderConnectionController(options: {
dispatch({ type: "auth.pending" })
if (selected.type === "external") {
const saved = await serverSDK.api.integration.connect
.external({
integrationID: options.provider(),
methodID: selected.id,
answer: Object.keys(merged).length ? merged : undefined,
location: location(),
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!saved.ok) {
dispatch({ type: "auth.error", error: formatServerError(saved.error, language.t) })
return
}
await finish()
return
}
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(Object.keys(merged).length ? { answer: merged } : {}),
answer: Object.keys(merged).length ? merged : undefined,
location: location(),
})
.then((response) => {
@@ -404,7 +437,9 @@ export function createProviderConnectionController(options: {
if (!result.ok) {
dispatch({
type: "auth.error",
error: isConsole() ? language.t("provider.connect.console.startFailed") : errorMessage(result.error),
error: isConsole()
? language.t("provider.connect.console.startFailed")
: formatServerError(result.error, language.t),
})
return
@@ -448,7 +483,7 @@ export function createProviderConnectionController(options: {
integrationID: options.keyProvider?.() ?? options.provider(),
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
answer: store.formAnswer,
})
await finish()
}
@@ -468,7 +503,8 @@ export function createProviderConnectionController(options: {
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) return errorMessage(result.error) || language.t("provider.connect.oauth.code.invalid")
if (!result.ok)
return formatServerError(result.error, language.t, language.t("provider.connect.oauth.code.invalid"))
await finish()
return undefined
@@ -496,11 +532,13 @@ export function createProviderConnectionController(options: {
authorization: () => store.authorization,
browserFailed: () => store.browserFailed,
// True while nothing useful can be shown yet: the integration is loading, a method is
// about to be picked automatically, or the authorization request is in flight.
// about to be picked automatically, the authorization request is in flight, or an external
// method, which has no view of its own, is refreshing the catalogs after saving.
busy: () =>
integration.loading ||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
store.state === "pending",
store.state === "pending" ||
(store.state === "refreshing" && currentMethod()?.type === "external"),
auth: {
state: () => store.state,
error: () => store.error,
@@ -66,8 +66,9 @@ export const OpenCodeZen = {
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "oauth", label: "Sign in with ChatGPT" },
{ type: "oauth", label: "Codex browser (legacy)" },
{ type: "oauth", label: "Codex device code (legacy)" },
{ type: "key", label: "API key" },
]),
}
+128 -28
View File
@@ -3,6 +3,7 @@ import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { Spinner } from "@opencode/ui/spinner"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
@@ -61,7 +62,7 @@ type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController() {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [store, setStore] = createStore<{ selected?: string }>({})
const reset = () => setStore("selected", undefined)
return {
@@ -83,9 +84,13 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const [state, setState] = createStore({
const [state, setState] = createStore<{
completed: boolean
modelProvider?: { id: string; name: string }
authorization: boolean
chatgptWelcome: boolean
}>({
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
chatgptWelcome: false,
})
@@ -216,10 +221,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const integrations = useIntegrations(() => props.directory)
const language = useLanguage()
const [store, setStore] = createStore({
const [store, setStore] = createStore<{
filter: string
active?: string
connecting?: string
}>({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
@@ -421,16 +428,24 @@ function ProviderConnection(props: {
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
const remote = isConsole && authServerName(sdk.server) !== undefined
const [state, setState] = createStore({
const [state, setState] = createStore<{
copied: boolean
copyFailed: boolean
firstConnection?: boolean
models: boolean
noModels: boolean
catalogPending: boolean
selectedModel: string
collapsed: Record<string, boolean>
}>({
copied: false,
copyFailed: false,
firstConnection: undefined as boolean | undefined,
models: false,
noModels: false,
// The workspace providers had not loaded when the wait ran out.
catalogPending: false,
selectedModel: "",
collapsed: {} as Record<string, boolean>,
collapsed: {},
})
const controller = createProviderConnectionController({
@@ -642,11 +657,22 @@ function ProviderConnection(props: {
function AuthFormView() {
const defaults = providerFormDefaults(controller.currentMethod()?.form)
const [formStore, setFormStore] = createStore({
const [formStore, setFormStore] = createStore<{
value: Record<string, string>
index: number
customOn: boolean
customText: string
}>({
value: Object.fromEntries(
Object.entries(defaults).flatMap(([key, value]) => (typeof value === "string" ? [[key, value]] : [])),
) as Record<string, string>,
(controller.currentMethod()?.form ?? []).flatMap((field) =>
field.type === "string" && field.key in defaults && field.default !== undefined
? [[field.key, field.default]]
: [],
),
),
index: 0,
customOn: false,
customText: "",
})
const fields = createMemo<StringForm[]>(() => {
@@ -677,14 +703,18 @@ function ProviderConnection(props: {
}
})
// Visible defaults prefill the field but enter the answer only once that field is answered,
// because the server rejects values for fields whose conditions are not met.
const answer = (field: StringForm) => formStore.value[field.key] ?? field.default
const valid = createMemo(() => {
const item = current()
if (!item || item.field.options) return false
if (!item || (item.field.options && !item.field.custom)) return false
if (!item.field.required) return true
return (formStore.value[item.field.key] ?? "").trim().length > 0
return (answer(item.field) ?? "").trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
@@ -694,7 +724,7 @@ function ProviderConnection(props: {
const next = fields().findIndex((field, i) => i > index && matches(field, value))
if (next !== -1) {
setFormStore("index", next)
setFormStore({ index: next, customOn: false, customText: "" })
return
}
@@ -706,9 +736,10 @@ function ProviderConnection(props: {
e.preventDefault()
const item = current()
if (!item || item.field.options) return
if (!item || !valid()) return
const value = answer(item.field)
if (!valid()) return
if (value !== undefined) setFormStore("value", item.field.key, value)
await next(item.index, formStore.value)
}
@@ -730,6 +761,15 @@ function ProviderConnection(props: {
return field
})
const custom = () => {
const field = select()
if (!field?.custom) return false
const value = answer(field)
return formStore.customOn || (value !== undefined && !field.options?.some((option) => option.value === value))
}
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
@@ -738,7 +778,7 @@ function ProviderConnection(props: {
type="text"
label={text()?.title ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
value={text() ? (answer(text()!) ?? "") : ""}
onChange={(value) => {
const field = text()
@@ -750,6 +790,62 @@ function ProviderConnection(props: {
{language.t("common.continue")}
</Button>
</Match>
<Match when={select()?.custom && select()}>
{(field) => (
<>
<div class="w-full flex flex-col gap-3">
<div class="text-14-regular text-text-base">{field().title}</div>
<RadioGroup
label={field().title}
hideLabel
description={field().description}
value={
custom()
? "custom"
: String(field().options?.findIndex((option) => option.value === answer(field())))
}
onChange={(value) => {
const option = field().options?.[Number(value)]
if (option) {
if (custom()) setFormStore("customText", answer(field()) ?? "")
setFormStore("customOn", false)
setFormStore("value", field().key, option.value)
return
}
setFormStore("customOn", true)
setFormStore("value", field().key, formStore.customText)
}}
>
<For each={field().options}>
{(option, index) => (
<RadioItem value={String(index())} label={option.label} description={option.description} />
)}
</For>
<RadioItem value="custom" label={language.t("ui.messagePart.option.typeOwnAnswer")} />
<Show when={custom()}>
<div class="pl-6">
<TextField
ref={(input: HTMLInputElement) => queueMicrotask(() => input.focus())}
type="text"
label={language.t("ui.messagePart.option.typeOwnAnswer")}
hideLabel
placeholder={field().placeholder ?? language.t("ui.question.custom.placeholder")}
value={answer(field()) ?? ""}
onChange={(value) => setFormStore("value", field().key, value)}
/>
</div>
</Show>
</RadioGroup>
</div>
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</>
)}
</Match>
<Match when={item()?.field.options !== undefined}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.title}</div>
@@ -758,7 +854,7 @@ function ProviderConnection(props: {
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
current={select()?.options?.find((x) => x.value === answer(select()!))}
onSelect={(value) => {
if (!value) return
const field = select()
@@ -876,9 +972,11 @@ function ProviderConnection(props: {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
const [formStore, setFormStore] = createStore<{
value: string
error?: string
}>({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
@@ -890,16 +988,16 @@ function ProviderConnection(props: {
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("apiKey")
const apiKey = typeof value === "string" ? value : ""
const key = value instanceof File || value === null ? "" : value
if (!apiKey?.trim()) {
if (!key.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await controller.auth.connectKey(apiKey)
await controller.auth.connectKey(key)
}
return (
@@ -954,9 +1052,11 @@ function ProviderConnection(props: {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
const [formStore, setFormStore] = createStore<{
value: string
error?: string
}>({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
@@ -968,9 +1068,9 @@ function ProviderConnection(props: {
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("code")
const code = typeof value === "string" ? value : ""
const code = value instanceof File || value === null ? "" : value
if (!code?.trim()) {
if (!code.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
+19 -30
View File
@@ -1,7 +1,5 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { useFilteredList } from "@opencode/ui/hooks"
@@ -24,7 +22,7 @@ export const DialogManageModels: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
const [store, setStore] = createStore<{ collapsed: Record<string, boolean> }>({ collapsed: {} })
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
@@ -107,33 +105,24 @@ export const DialogManageModels: Component = () => {
</DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<div class="px-4 pt-px pb-3">
<div class="relative">
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("dialog.model.search.placeholder")}
showClearButton={!!list.filter()}
clearIcon="circle-xmark"
clearLabel={language.t("common.clear")}
onClearClick={() => list.clear()}
/>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
@@ -0,0 +1,38 @@
[data-slot="mobile-drawer-content"]:has([data-slot="model-selector-drawer"]) {
padding-left: 0;
padding-right: 0;
}
[data-slot="model-selector-drawer"] {
/* Keep the gutter inside each scroll boundary so outlines are not clipped. */
[data-slot="model-selector-search"],
.settings-models,
[data-slot="model-selector-actions"] {
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
}
[data-component="settings-list"][data-variant="catalog"] {
padding-inline: 12px;
}
[data-component="settings-row"] {
width: calc(100% + 24px);
margin-inline: -12px;
padding-inline: 12px;
}
.settings-models-console-groups {
margin-inline-start: 4px;
padding-inline-start: 8px;
}
.provider-model-group-trigger,
.provider-model-group-models {
padding-inline: 12px;
}
.provider-model-group-models > [data-component="settings-list"] {
padding-inline: 0;
}
}
@@ -1,6 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show, Suspense, lazy, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
@@ -8,7 +9,6 @@ import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
@@ -24,8 +24,20 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
import {
CONSOLE_GROUP_KEY,
consoleModelGroup,
ProviderModelIcon,
ProviderModelSections,
} from "@/providers/models/provider-group"
import "@/settings/settings.css"
import "./select-dialog.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
})
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -54,6 +66,8 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
}
const ModelList: Component<{
mobile?: boolean
open?: boolean
provider?: string
onSelect: () => void
model?: ModelState
@@ -66,12 +80,21 @@ const ModelList: Component<{
onSelect: props.onSelect,
})
const [store, setStore] = createStore({
const [store, setStore] = createStore<{ search: string; active: string; collapsed: Record<string, boolean> }>({
search: "",
active: "",
collapsed: {} as Record<string, boolean>,
active: props.mobile ? (controller.current() ?? "") : "",
collapsed: {},
})
createEffect(
on(
() => props.open,
(open) => {
if (!props.mobile || !open) return
setStore({ search: "", active: controller.current() ?? "" })
},
),
)
const models = createMemo(() => controller.models(store.search))
const modelGroups = createMemo(() => controller.groups(models()))
const managed = createMemo(() => consoleModelGroup(controller.all()))
@@ -110,7 +133,7 @@ const ModelList: Component<{
if (item) controller.select(item)
}
function ModelRows(props: { items: ModelItem[] }) {
function ModelRows(props: { items: ModelItem[]; mobile?: boolean }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
@@ -119,6 +142,7 @@ const ModelList: Component<{
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
aria-pressed={controller.current() === modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
@@ -128,6 +152,7 @@ const ModelList: Component<{
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
inactive={props.mobile}
placement="right-start"
gutter={12}
openDelay={0}
@@ -159,59 +184,54 @@ const ModelList: Component<{
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
<div class="relative">
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.altKey || event.metaKey) return
<div data-slot="model-selector-search" class="shrink-0 pt-px pb-3" classList={{ "px-4": !props.mobile }}>
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus={!props.mobile}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={store.search}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
aria-label={language.t("dialog.model.search.placeholder")}
showClearButton={!!store.search}
clearIcon="circle-xmark"
clearLabel={language.t("common.clear")}
onClearClick={() => setSearch("")}
/>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<div class="relative min-h-0" classList={{ "flex-1": !props.mobile }}>
<div
ref={(element) => (scrollRef = element)}
class="settings-panel settings-models pt-1 pb-4"
classList={{ "h-full px-4": !props.mobile, "max-h-[min(360px,40dvh)]": props.mobile }}
>
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
@@ -222,7 +242,7 @@ const ModelList: Component<{
expanded={expanded}
disabled={store.search.length > 0}
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
rows={(items) => <ModelRows items={items} />}
rows={(items) => <ModelRows items={items} mobile={props.mobile} />}
/>
</Show>
</div>
@@ -238,10 +258,12 @@ type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
unpaid?: boolean
trigger: ModelSelectorTrigger
onClose?: () => void
}) {
const dialog = useDialog()
const mobile = createMediaQuery("(max-width: 767px)")
const data = useData()
const location = useWorkspaceLocation()
@@ -256,27 +278,171 @@ export function ModelSelectorPopover(props: {
const connection = data.location.integration
.list(location().ref)
?.find((integration) => integration.id === "openai")
?.connections[0]
?.find((integration) => integration.id === "openai")?.connections[0]
return connection?.type === "credential" && connection.method === "oauth"
}
const manage = async () => {
const { DialogManageModels } = await import("./manage")
void dialog.show(() => <DialogManageModels />)
}
const connect = async () => {
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => <DialogConnectProvider directory={location().directory} />)
}
return (
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={() => {
void import("./manage").then((module) => {
void dialog.show(() => <module.DialogManageModels />)
})
}}
onClose={() => props.onClose?.()}
/>
<Show
when={mobile()}
fallback={
<ModelSelectorPopoverView
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={manage}
onClose={() => props.onClose?.()}
/>
}
>
<ModelSelectorDrawer
trigger={props.trigger}
model={props.model}
provider={props.provider}
onConnect={props.unpaid ? connect : undefined}
chatgptPlan={chatgptPlan()}
onClose={() => props.onClose?.()}
onManage={manage}
/>
</Show>
)
}
function ModelSelectorDrawer(props: {
trigger: ModelSelectorTrigger
model?: ModelState
provider?: string
chatgptPlan?: boolean
onClose: () => void
onManage: () => void
onConnect?: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore<{
open: boolean
loaded: boolean
restoreTrigger: boolean
action?: "select" | "manage" | "connect"
}>({
open: false,
loaded: false,
restoreTrigger: true,
})
let trigger: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
return (
<>
<div ref={trigger} class="min-w-0">
{props.trigger({
"aria-haspopup": "dialog",
get "aria-expanded"() {
return store.open
},
onClick: () => setStore({ open: true, loaded: true, restoreTrigger: true }),
})}
</div>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
hideHeader
title={language.t("dialog.model.select.title")}
open={store.open}
onOpenChange={(open) => setStore("open", open)}
initialFocus={() => content}
returnFocus={() => trigger?.querySelector("button") ?? undefined}
onFinalFocus={(event) => {
if (!store.restoreTrigger) event.preventDefault()
}}
onContentPresentChange={(present) => {
if (present) return
const action = store.action
setStore("action", undefined)
if (action === "manage") {
props.onManage()
return
}
if (action === "connect") {
props.onConnect?.()
return
}
if (action === "select") queueMicrotask(props.onClose)
}}
>
<div
ref={content}
tabIndex={-1}
data-slot="model-selector-drawer"
class="flex min-h-0 flex-col gap-2 outline-none"
>
<div class="flex min-h-0 flex-col">
<ModelList
mobile
open={store.open}
model={props.model}
provider={props.provider}
onSelect={() => setStore({ open: false, action: "select", restoreTrigger: false })}
/>
</div>
<div data-slot="model-selector-actions" class="flex flex-col gap-2">
<Button
variant="ghost"
class="w-full !h-10 !justify-start"
icon="outline-sliders"
onClick={() => setStore({ open: false, action: "manage", restoreTrigger: false })}
>
{language.t("dialog.model.manage")}
</Button>
<Show when={props.onConnect}>
<Button
variant="ghost"
class="w-full !h-10 !justify-start"
icon="plus"
onClick={() => setStore({ open: false, action: "connect", restoreTrigger: false })}
>
{language.t("command.provider.connect")}
</Button>
</Show>
<Show when={props.chatgptPlan}>
<div class="flex min-h-10 items-center gap-2 border-t border-v2-border-border-muted px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
<ExternalLink
href="https://chatgpt.com/settings/usage"
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("dialog.model.chatgptManageUsage")}
<Icon name="arrow-up-right" size="small" />
</ExternalLink>
</div>
</Show>
</div>
</div>
</MobilePanelDrawer>
</Suspense>
</Show>
</>
)
}
@@ -437,7 +603,7 @@ export function ModelSelectorPopoverView(props: {
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => (searchRef = el)}
@@ -487,12 +653,12 @@ export function ModelSelectorPopoverView(props: {
<Show when={store.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
>
<Icon name="close" size="small" />
<Icon name="circle-xmark" />
</button>
</Show>
</div>
@@ -69,6 +69,7 @@ type Attached = {
keybind: (command: string) => readonly string[]
matches: (command: string, event: KeyboardEvent) => boolean
servers: Accessor<readonly string[]>
server: (id: string) => ServerRef | undefined
}
/**
@@ -269,7 +270,10 @@ export function createHostApis() {
keys: (bind) => formatKeybindParts(bind, language.t),
matches: (command, event) => current()?.matches(command, event) ?? false,
}),
servers: () => ({ list: () => current()?.servers() ?? [] }),
servers: () => ({
list: () => current()?.servers() ?? [],
get: (id) => current()?.server(id),
}),
workspaces: (_extension, _owner, _context, register) => ({
on(_event, handler) {
removed.add(handler)
@@ -389,15 +393,28 @@ export function createExtensionAttachment(apis: HostApis) {
if (existing) return existing
const key = ServerConnection.Key.make(id)
// The controller starts on first use, not when the ref is made: reading `builtin` to find a server must not
// connect to every listed server, such as one the runtime holds back because it rejects its credentials.
let started: Accessor<ServerCtx> | undefined
const live = runWithOwner(owner, () =>
createMemo<ServerCtx>((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)),
)!
const live = () => {
started ??= runWithOwner(owner, () =>
createMemo<ServerCtx>(
(previous) => global.serverCtx(key) ?? previous,
global.ensureServerCtx(connection(id) ?? conn),
),
)!
return started()
}
// What the app's server list already says, without a controller.
const listed = () => connection(id) ?? live().sdk.server
const ref: ServerRef = {
id,
get name() {
return serverName(live().sdk.server) || id
return serverName(listed()) || id
},
get url() {
return live().sdk.url
@@ -412,10 +429,10 @@ export function createExtensionAttachment(apis: HostApis) {
return live().data
},
get local() {
return ServerConnection.local(live().sdk.server)
return ServerConnection.local(listed())
},
get builtin() {
return ServerConnection.builtin(live().sdk.server)
return ServerConnection.builtin(listed())
},
get compatible() {
return !global.servers.health[key]?.incompatible
@@ -783,6 +800,7 @@ export function createExtensionAttachment(apis: HostApis) {
keybind: command.keybindParts,
matches: command.matches,
servers: () => global.servers.list().map(ServerConnection.key),
server,
// SAFETY: an extension names a page it contributed through `SettingsPage`, which settings lists as an extension tab.
settings: (page) => surface.open(page as Parameters<typeof surface.open>[0]),
layout: {
+91 -75
View File
@@ -2,10 +2,7 @@ import { createMemo, For, lazy, Show, Suspense, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import {
DrawerContext,
Panel,
type PanelSidebar,
type PanelTab,
@@ -57,7 +54,7 @@ export function createMobileViews() {
export type MobileViews = ReturnType<typeof createMobileViews>
/** The narrow-screen view switcher: the conversation, each panel's view, and an overflow menu. */
/** The narrow-screen view switcher: the conversation, each tab view, and a More drawer with the other views. */
export function MobileViewTabs(props: {
views: MobileViews
region: Region
@@ -65,34 +62,38 @@ export function MobileViewTabs(props: {
session: MountedSession
screen: SessionScreen
sidebar: PanelSidebar
bottom?: boolean
onSelect: (key: string) => void
}): JSX.Element {
const language = useLanguage()
const [store, setStore] = createStore<{
menu: boolean
drawer: string | undefined
last: string | undefined
open: boolean
loaded: boolean
pending: string | undefined
drawer: string | undefined
}>({
menu: false,
drawer: undefined,
// Keeps the last drawer's content mounted while it animates closed.
last: undefined,
open: false,
loaded: false,
pending: undefined,
drawer: undefined,
})
const drawer = createMemo(() => (store.last ? props.views.find(store.last) : undefined))
const drawer = createMemo(() => (store.drawer ? props.views.find(store.drawer) : undefined))
const tab = createMemo(() =>
props.current === "session" || props.views.tabs().some((entry) => entry.key === props.current)
? props.current
: "more",
)
let trigger: HTMLButtonElement | undefined
return (
<div
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:h-px before:bg-v2-border-border-base before:content-['']"
classList={{ "before:top-0": props.bottom, "before:bottom-0": !props.bottom }}
data-slot="session-mobile-view-navigation"
>
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs value={tab()} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
<Tabs.Trigger
value="session"
@@ -114,73 +115,88 @@ export function MobileViewTabs(props: {
</Tabs.Trigger>
)}
</For>
<Show when={props.views.menu().length}>
<Tabs.Trigger
value="more"
ref={(element: HTMLButtonElement) => {
trigger = element
}}
class="min-w-0 flex-1"
classes={{ button: "w-full justify-center" }}
aria-haspopup="dialog"
aria-expanded={store.open}
onClick={() => setStore({ open: true, loaded: true, drawer: undefined })}
>
{language.t("session.tab.more")}
</Tabs.Trigger>
</Show>
</Tabs.List>
</Tabs>
<Menu
appearance="standard"
modal={false}
placement="bottom-end"
gutter={4}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
>
<Menu.Trigger
as={IconButton}
ref={(element: HTMLButtonElement) => {
trigger = element
}}
icon={<Icon name="menu" />}
variant="ghost-muted"
size="normal"
class="mx-1.5 shrink-0"
state={props.views.menu().some((entry) => entry.key === props.current) || store.menu ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
setStore({ drawer: store.pending, last: store.pending, loaded: true, pending: undefined })
}}
>
<For each={props.views.menu()}>
{(entry) => (
<Menu.Item
onSelect={() => {
if (entry.mobile.kind === "menu") return props.onSelect(entry.key)
setStore({ pending: entry.key, menu: false })
}}
>
{entry.mobile.title}
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.loaded}>
<Suspense>
<MobilePanelDrawer
title={drawer()?.mobile.title ?? ""}
open={!!store.drawer}
onOpenChange={(open) => {
if (!open) setStore("drawer", undefined)
}}
title={drawer()?.mobile.title ?? language.t("common.moreOptions")}
hideHeader
open={store.open}
onOpenChange={(open) => setStore("open", open)}
returnFocus={() => trigger}
>
<Show when={drawer()} keyed>
<Show
when={drawer()}
keyed
fallback={
<div
data-slot="session-mobile-view-options"
class="flex flex-col gap-0.5 rounded-[6px] bg-v2-background-bg-base p-0.5 shadow-[var(--v2-elevation-raised)] [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01"
>
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "menu")}>
{(entry) => (
<button
type="button"
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)] aria-pressed:bg-[var(--v2-overlay-simple-overlay-pressed)]"
aria-pressed={props.current === entry.key}
onClick={() => {
props.onSelect(entry.key)
setStore("open", false)
}}
>
<Show when={entry.mobile.icon}>
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
<Show when={props.current === entry.key}>
<Icon name="check" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "drawer")}>
{(entry) => (
<button
type="button"
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)]"
onClick={() => setStore("drawer", entry.key)}
>
<Show when={entry.mobile.icon}>
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
<Icon name="chevron-right" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
</button>
)}
</For>
</div>
}
>
{(entry) => (
<DrawerContext.Provider value={{ close: () => setStore("drawer", undefined) }}>
<MobilePanel
entry={entry}
view={props.session}
screen={props.screen}
sidebar={props.sidebar}
visible={store.drawer === entry.key}
open={() => props.region.openFor(entry.extension)}
/>
</DrawerContext.Provider>
<MobilePanel
entry={entry}
view={props.session}
screen={props.screen}
sidebar={props.sidebar}
visible={store.open}
open={() => props.region.openFor(entry.extension)}
/>
)}
</Show>
</MobilePanelDrawer>
-5
View File
@@ -285,9 +285,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "የይለፍ ቃል",
"dialog.server.edit.title": "አገልጋይ አርትዕ",
"dialog.server.menu.edit": "አርትዕ",
"dialog.server.menu.default": "ነባሪ አዘጋጅ",
"dialog.server.menu.defaultRemove": "ነባሪውን አስወግድ",
"dialog.server.status.default": "ነባሪ",
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
"dialog.project.edit.icon": "አዶ",
"dialog.project.edit.icon.alt": "የፕሮጀክት አዶ",
@@ -775,14 +772,12 @@ export const dict = {
"server.connect.button": "መገናኘት",
"server.connect.address.invalid": "የሚሰራ የኤችቲቲፒ ወይም የኤችቲቲፒኤስ አገልጋይ አድራሻ ያስገቡ።",
"server.connect.failed": "መገናኘት አልተቻለም። የአገልጋዩን አድራሻ እና የይለፍ ቃል ያረጋግጡ እና ከዚያ እንደገና ይሞክሩ።",
"server.connect.pair.description": "የግንኙነት ዝርዝሮችዎን ለማግኘት ይህንን ትዕዛዝ በኮምፒተርዎ ላይ ያሂዱ።",
"server.connect.scan": "QR ኮድ ይቃኙ",
"server.connect.scan.description": "ካሜራዎን opencode pair ወደሚያሳየው QR ኮድ ያነጣጥሩ።",
"server.connect.scan.invalid": "ይህ የOpenCode ማጣመሪያ ኮድ አይደለም። opencode pair የሚያሳየውን ኮድ ይቃኙ።",
"server.connect.camera": "ማጣመር ካሜራ",
"server.connect.camera.starting": "ካሜራ በመክፈት ላይ...",
"server.connect.mixedContent": "ከዚህ ኤችቲቲፒኤስ አገልጋይ ጋር ከኤችቲቲፒኤስ ገጽ ጋር መገናኘት አልተቻለም። በምትኩ የኤችቲቲፒኤስ አገልጋይ አድራሻ ይጠቀሙ።",
"server.connect.camera.insecure": "QR ቅኝት ይህንን ገጽ በኤችቲቲፒኤስ ወይም በአካባቢያዊ አስተናጋጅ መክፈት ይጠይቃል።",
"server.connect.camera.unavailable": "ለዚህ አሳሽ ምንም ካሜራ አይገኝም። የግንኙነት ዝርዝሮችዎን እራስዎ ያስገቡ።",
"server.connect.camera.error": "ካሜራውን መክፈት አልተቻለም። የካሜራ መዳረሻ ይፍቀዱ ወይም የግንኙነት ዝርዝሮችዎን እራስዎ ያስገቡ።",
"dialog.server.menu.remove": "አስወግድ",
-5
View File
@@ -297,9 +297,6 @@ export const dict = {
"dialog.server.add.password": "كلمة المرور (اختياري)",
"dialog.server.edit.title": "تحرير الخادم",
"dialog.server.menu.edit": "تعديل",
"dialog.server.menu.default": "تعيين كافتراضي",
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
"dialog.server.status.default": "افتراضي",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.icon": "أيقونة",
"dialog.project.edit.icon.alt": "أيقونة المشروع",
@@ -833,14 +830,12 @@ export const dict = {
"server.connect.button": "اتصال",
"server.connect.address.invalid": "أدخل عنوان خادم HTTP أو HTTPS صالحًا.",
"server.connect.failed": "تعذر الاتصال. تحقق من عنوان الخادم وكلمة المرور، ثم أعد المحاولة.",
"server.connect.pair.description": "نفّذ هذا الأمر على حاسوبك للحصول على تفاصيل الاتصال.",
"server.connect.scan": "مسح رمز QR",
"server.connect.scan.description": "وجّه الكاميرا نحو رمز QR الذي يعرضه opencode pair.",
"server.connect.scan.invalid": "هذا ليس رمز إقران OpenCode. امسح الرمز الذي يعرضه opencode pair.",
"server.connect.camera": "كاميرا الإقران",
"server.connect.camera.starting": "جارٍ فتح الكاميرا…",
"server.connect.mixedContent": "تعذر الاتصال بخادم HTTP هذا من صفحة HTTPS. استخدم عنوان خادم HTTPS بدلًا منه.",
"server.connect.camera.insecure": "يتطلب مسح رمز QR فتح هذه الصفحة عبر HTTPS أو على localhost.",
"server.connect.camera.unavailable": "لا تتوفر كاميرا لهذا المتصفح. أدخل تفاصيل الاتصال يدويًا.",
"server.connect.camera.error": "تعذر فتح الكاميرا. اسمح بالوصول إليها أو أدخل تفاصيل الاتصال يدويًا.",
"dialog.server.menu.remove": "إزالة",
-6
View File
@@ -288,9 +288,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "parol",
"dialog.server.edit.title": "Serveri redaktə et",
"dialog.server.menu.edit": "Redaktə et",
"dialog.server.menu.default": "Standart olaraq təyin et",
"dialog.server.menu.defaultRemove": "Standartı sil",
"dialog.server.status.default": "Standart",
"dialog.project.edit.title": "Layihəni redaktə et",
"dialog.project.edit.icon": "İkon",
"dialog.project.edit.icon.alt": "Layihə ikonu",
@@ -799,7 +796,6 @@ export const dict = {
"server.connect.button": "Qoşulun",
"server.connect.address.invalid": "Etibarlı HTTP və ya HTTPS server ünvanını daxil edin.",
"server.connect.failed": "Bağlana bilmədik. Server ünvanını və şifrəni yoxlayın, sonra yenidən cəhd edin.",
"server.connect.pair.description": "Bağlantı məlumatlarınızı almaq üçün bu əmri kompüterinizdə işlədin.",
"server.connect.scan": "QR kodu skan edin",
"server.connect.scan.description": "Kameranızı opencode pair tərəfindən göstərilən QR koda yönəldin.",
"server.connect.scan.invalid": "Bu, OpenCode qoşulma kodu deyil. opencode pair tərəfindən göstərilən kodu skan edin.",
@@ -807,8 +803,6 @@ export const dict = {
"server.connect.camera.starting": "Kamera açılır…",
"server.connect.mixedContent":
"Bu HTTPS səhifəsindən bu HTTP serverinə qoşulmaq mümkün olmadı. Bunun əvəzinə HTTPS server ünvanından istifadə edin.",
"server.connect.camera.insecure":
"QR skan edilməsi üçün bu səhifəni HTTPS üzərindən və ya localhost-da açmaq tələb olunur.",
"server.connect.camera.unavailable":
"Bu brauzer üçün heç bir kamera mövcud deyil. Bağlantı məlumatlarınızı əl ilə daxil edin.",
"server.connect.camera.error":
-6
View File
@@ -287,9 +287,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "парола",
"dialog.server.edit.title": "Редактиране на сървъра",
"dialog.server.menu.edit": "Редактиране",
"dialog.server.menu.default": "Задайте по подразбиране",
"dialog.server.menu.defaultRemove": "Премахване на подразбиране",
"dialog.server.status.default": "По подразбиране",
"dialog.project.edit.title": "Редактиране на проекта",
"dialog.project.edit.icon": "Икона",
"dialog.project.edit.icon.alt": "Икона на проекта",
@@ -797,7 +794,6 @@ export const dict = {
"server.connect.button": "Свързване",
"server.connect.address.invalid": "Въведете валиден HTTP или HTTPS адрес на сървър.",
"server.connect.failed": "Свързването е неуспешно. Проверете адреса и паролата на сървъра и опитайте отново.",
"server.connect.pair.description": "Изпълнете тази команда на компютъра си, за да получите данните за връзка.",
"server.connect.scan": "Сканиране на QR код",
"server.connect.scan.description": "Насочете камерата към QR кода, показан от opencode pair.",
"server.connect.scan.invalid": "Това не е код за сдвояване с OpenCode. Сканирайте кода, показан от opencode pair.",
@@ -805,8 +801,6 @@ export const dict = {
"server.connect.camera.starting": "Отваряне на камерата…",
"server.connect.mixedContent":
"Не може да се осъществи връзка с този HTTP сървър от HTTPS страница. Използвайте HTTPS адрес на сървъра.",
"server.connect.camera.insecure":
"Сканирането на QR код изисква тази страница да е отворена чрез HTTPS или на localhost.",
"server.connect.camera.unavailable": "Няма достъпна камера за този браузър. Въведете ръчно данните за връзка.",
"server.connect.camera.error":
"Камерата не можа да бъде отворена. Разрешете достъпа до нея или въведете ръчно данните за връзка.",
-5
View File
@@ -290,9 +290,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "পাসওয়ার্ড",
"dialog.server.edit.title": "সার্ভার সম্পাদনা করুন",
"dialog.server.menu.edit": "সম্পাদনা করুন",
"dialog.server.menu.default": "ডিফল্ট হিসেবে সেট করুন",
"dialog.server.menu.defaultRemove": "ডিফল্ট সরান",
"dialog.server.status.default": "ডিফল্ট",
"dialog.project.edit.title": "প্রকল্প সম্পাদনা করুন",
"dialog.project.edit.icon": "আইকন",
"dialog.project.edit.icon.alt": "প্রকল্প আইকন",
@@ -797,7 +794,6 @@ export const dict = {
"server.connect.button": "সংযোগ করুন",
"server.connect.address.invalid": "একটি বৈধ HTTP বা HTTPS সার্ভার ঠিকানা লিখুন৷",
"server.connect.failed": "সংযোগ করা যায়নি। সার্ভার ঠিকানা এবং পাসওয়ার্ড পরীক্ষা করুন, তারপর আবার চেষ্টা করুন.",
"server.connect.pair.description": "আপনার সংযোগের বিবরণ পেতে আপনার কম্পিউটারে এই কমান্ডটি চালান।",
"server.connect.scan": "QR কোড স্ক্যান করুন",
"server.connect.scan.description": "আপনার ক্যামেরাকে ওপেনকোড পেয়ার দ্বারা দেখানো QR কোডে নির্দেশ করুন৷",
"server.connect.scan.invalid": "এটি একটি OpenCode পেয়ারিং কোড নয়৷ ওপেনকোড পেয়ার দ্বারা দেখানো কোডটি স্ক্যান করুন।",
@@ -805,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "ক্যামেরা খোলা হচ্ছে...",
"server.connect.mixedContent":
"একটি HTTPS পৃষ্ঠা থেকে এই HTTP সার্ভারের সাথে সংযোগ করা যায়নি৷ পরিবর্তে একটি HTTPS সার্ভার ঠিকানা ব্যবহার করুন৷",
"server.connect.camera.insecure": "QR স্ক্যান করার জন্য এই পৃষ্ঠাটি HTTPS বা লোকালহোস্টে খুলতে হবে।",
"server.connect.camera.unavailable": "এই ব্রাউজারে কোন ক্যামেরা উপলব্ধ নেই৷ ম্যানুয়ালি আপনার সংযোগ বিশদ লিখুন.",
"server.connect.camera.error":
"ক্যামেরা খুলতে পারিনি। ক্যামেরা অ্যাক্সেসের অনুমতি দিন বা ম্যানুয়ালি আপনার সংযোগের বিবরণ লিখুন।",
-5
View File
@@ -22,7 +22,6 @@ export const dict = {
"server.connect.button": "Conectar",
"server.connect.address.invalid": "Insira um endereço de servidor HTTP ou HTTPS válido.",
"server.connect.failed": "Não foi possível conectar. Verifique o endereço e a senha do servidor e tente novamente.",
"server.connect.pair.description": "Execute este comando em seu computador para obter os detalhes de sua conexão.",
"server.connect.scan": "Digitalize o código QR",
"server.connect.scan.description": "Aponte sua câmera para o código QR mostrado por opencode pair.",
"server.connect.scan.invalid":
@@ -31,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Abrindo a câmera…",
"server.connect.mixedContent":
"Não foi possível conectar-se a este servidor HTTP a partir de uma página HTTPS. Use um endereço de servidor HTTPS.",
"server.connect.camera.insecure": "A varredura QR requer a abertura desta página em HTTPS ou no host local.",
"server.connect.camera.unavailable":
"Nenhuma câmera está disponível para este navegador. Insira os detalhes da sua conexão manualmente.",
"server.connect.camera.error":
@@ -510,9 +508,6 @@ export const dict = {
"dialog.server.add.password": "Senha (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Definir como padrão",
"dialog.server.menu.defaultRemove": "Remover padrão",
"dialog.server.status.default": "Padrão",
"dialog.project.edit.title": "Editar projeto",
"dialog.project.edit.icon": "Ícone",
"dialog.project.edit.icon.alt": "Ícone do projeto",
-6
View File
@@ -312,9 +312,6 @@ export const dict = {
"dialog.server.edit.title": "Uredi server",
"dialog.server.menu.edit": "Uredi",
"dialog.server.menu.default": "Postavi kao podrazumijevano",
"dialog.server.menu.defaultRemove": "Ukloni podrazumijevano",
"dialog.server.status.default": "Podrazumijevano",
"dialog.project.edit.title": "Uredi projekat",
"dialog.project.edit.icon": "Ikonica",
@@ -862,7 +859,6 @@ export const dict = {
"server.connect.button": "Poveži se",
"server.connect.address.invalid": "Unesite ispravnu HTTP ili HTTPS adresu servera.",
"server.connect.failed": "Povezivanje nije uspjelo. Provjerite adresu servera i lozinku, pa pokušajte ponovo.",
"server.connect.pair.description": "Pokrenite ovu naredbu na računaru da biste dobili podatke za povezivanje.",
"server.connect.scan": "Skeniraj QR kod",
"server.connect.scan.description": "Usmjerite kameru prema QR kodu koji prikazuje opencode pair.",
"server.connect.scan.invalid": "Ovo nije OpenCode kod za uparivanje. Skenirajte kod koji prikazuje opencode pair.",
@@ -870,8 +866,6 @@ export const dict = {
"server.connect.camera.starting": "Otvaranje kamere…",
"server.connect.mixedContent":
"Nije se moguće povezati s ovim HTTP serverom s HTTPS stranice. Umjesto toga koristite HTTPS adresu servera.",
"server.connect.camera.insecure":
"Za skeniranje QR koda ovu stranicu morate otvoriti putem HTTPS-a ili na localhostu.",
"server.connect.camera.unavailable": "Ovom pregledniku nije dostupna kamera. Ručno unesite podatke za povezivanje.",
"server.connect.camera.error":
"Kameru nije bilo moguće otvoriti. Dozvolite pristup kameri ili ručno unesite podatke za povezivanje.",
-6
View File
@@ -286,9 +286,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "contrasenya",
"dialog.server.edit.title": "Edita el servidor",
"dialog.server.menu.edit": "Edita",
"dialog.server.menu.default": "Estableix com a predeterminat",
"dialog.server.menu.defaultRemove": "Elimina el valor predeterminat",
"dialog.server.status.default": "Per defecte",
"dialog.project.edit.title": "Edita el projecte",
"dialog.project.edit.icon": "Icona",
"dialog.project.edit.icon.alt": "Icona del projecte",
@@ -796,8 +793,6 @@ export const dict = {
"server.connect.address.invalid": "Introduïu una adreça de servidor HTTP o HTTPS vàlida.",
"server.connect.failed":
"No s'ha pogut connectar. Comproveu l'adreça i la contrasenya del servidor i torneu-ho a provar.",
"server.connect.pair.description":
"Executeu aquesta ordre al vostre ordinador per obtenir els detalls de la vostra connexió.",
"server.connect.scan": "Escaneja el codi QR",
"server.connect.scan.description": "Apunteu la càmera al codi QR que mostra opencode pair.",
"server.connect.scan.invalid":
@@ -806,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "Obertura de la càmera...",
"server.connect.mixedContent":
"No s'ha pogut connectar a aquest servidor HTTP des d'una pàgina HTTPS. Utilitzeu una adreça de servidor HTTPS.",
"server.connect.camera.insecure": "L'exploració de QR requereix obrir aquesta pàgina a HTTPS o a localhost.",
"server.connect.camera.unavailable":
"No hi ha cap càmera disponible per a aquest navegador. Introduïu els detalls de connexió manualment.",
"server.connect.camera.error":
-5
View File
@@ -286,9 +286,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "heslo",
"dialog.server.edit.title": "Upravit server",
"dialog.server.menu.edit": "Upravit",
"dialog.server.menu.default": "Nastavit jako výchozí",
"dialog.server.menu.defaultRemove": "Odebrat výchozí",
"dialog.server.status.default": "Výchozí",
"dialog.project.edit.title": "Upravit projekt",
"dialog.project.edit.icon": "ikona",
"dialog.project.edit.icon.alt": "Ikona projektu",
@@ -806,7 +803,6 @@ export const dict = {
"server.connect.button": "Připojit",
"server.connect.address.invalid": "Zadejte platnou adresu serveru HTTP nebo HTTPS.",
"server.connect.failed": "Připojení se nezdařilo. Zkontrolujte adresu serveru a heslo a zkuste to znovu.",
"server.connect.pair.description": "Spuštěním tohoto příkazu v počítači získáte údaje pro připojení.",
"server.connect.scan": "Naskenovat QR kód",
"server.connect.scan.description": "Namiřte kameru na QR kód zobrazený příkazem opencode pair.",
"server.connect.scan.invalid": "Toto není párovací kód OpenCode. Naskenujte kód zobrazený příkazem opencode pair.",
@@ -814,7 +810,6 @@ export const dict = {
"server.connect.camera.starting": "Otevírání kamery…",
"server.connect.mixedContent":
"K tomuto serveru HTTP se nelze připojit ze stránky HTTPS. Použijte adresu serveru HTTPS.",
"server.connect.camera.insecure": "Skenování QR kódu vyžaduje otevření této stránky přes HTTPS nebo na localhostu.",
"server.connect.camera.unavailable":
"Pro tento prohlížeč není dostupná žádná kamera. Zadejte údaje pro připojení ručně.",
"server.connect.camera.error":
-5
View File
@@ -23,7 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Indtast en gyldig HTTP- eller HTTPS-serveradresse.",
"server.connect.failed":
"Kunne ikke oprette forbindelse. Tjek serveradressen og adgangskoden, og prøv derefter igen.",
"server.connect.pair.description": "Kør denne kommando på din computer for at få dine forbindelsesdetaljer.",
"server.connect.scan": "Scan QR kode",
"server.connect.scan.description": "Ret dit kamera mod QR-koden vist af opencode pair.",
"server.connect.scan.invalid": "Dette er ikke en OpenCode-parringskode. Scan koden vist af opencode pair.",
@@ -31,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Åbner kamera...",
"server.connect.mixedContent":
"Kunne ikke oprette forbindelse til denne HTTP-server fra en HTTPS-side. Brug i stedet en HTTPS-serveradresse.",
"server.connect.camera.insecure": "QR-scanning kræver, at denne side åbnes over HTTPS eller på localhost.",
"server.connect.camera.unavailable":
"Intet kamera er tilgængeligt for denne browser. Indtast dine forbindelsesoplysninger manuelt.",
"server.connect.camera.error":
@@ -448,9 +446,6 @@ export const dict = {
"dialog.server.edit.title": "Rediger server",
"dialog.server.menu.edit": "Rediger",
"dialog.server.menu.default": "Sæt som standard",
"dialog.server.menu.defaultRemove": "Fjern som standard",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Rediger projekt",
"dialog.project.edit.icon": "Ikon",
-7
View File
@@ -27,8 +27,6 @@ export const dict = {
"server.connect.address.invalid": "Geben Sie eine gültige HTTP- oder HTTPS-Serveradresse ein.",
"server.connect.failed":
"Es konnte keine Verbindung hergestellt werden. Überprüfen Sie die Serveradresse und das Passwort und versuchen Sie es dann erneut.",
"server.connect.pair.description":
"Führen Sie diesen Befehl auf Ihrem Computer aus, um Ihre Verbindungsdetails abzurufen.",
"server.connect.scan": "Scannen Sie den QR-Code",
"server.connect.scan.description": "Richten Sie Ihre Kamera auf den von opencode pair angezeigten Code QR.",
"server.connect.scan.invalid":
@@ -37,8 +35,6 @@ export const dict = {
"server.connect.camera.starting": "Kamera wird geöffnet…",
"server.connect.mixedContent":
"Es konnte keine Verbindung zu diesem HTTP-Server von einer HTTPS-Seite hergestellt werden. Verwenden Sie stattdessen eine HTTPS-Serveradresse.",
"server.connect.camera.insecure":
"Zum Scannen mit QR muss diese Seite über HTTPS oder auf localhost geöffnet werden.",
"server.connect.camera.unavailable":
"Für diesen Browser ist keine Kamera verfügbar. Geben Sie Ihre Verbindungsdaten manuell ein.",
"server.connect.camera.error":
@@ -446,9 +442,6 @@ export const dict = {
"dialog.server.add.password": "Passwort (optional)",
"dialog.server.edit.title": "Server bearbeiten",
"dialog.server.menu.edit": "Bearbeiten",
"dialog.server.menu.default": "Als Standard festlegen",
"dialog.server.menu.defaultRemove": "Standard entfernen",
"dialog.server.status.default": "Standard",
"dialog.project.edit.title": "Projekt bearbeiten",
"dialog.project.edit.icon": "Symbol",
"dialog.project.edit.icon.alt": "Projektsymbol",
-6
View File
@@ -292,9 +292,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "ޕާސްވަރޑް",
"dialog.server.edit.title": "އެޑިޓް ސަރވަރ",
"dialog.server.menu.edit": "ބަދަލު ގެނައުން",
"dialog.server.menu.default": "ޑިފޯލްޓް ގޮތުގައި ސެޓްކުރުން",
"dialog.server.menu.defaultRemove": "ޑިފޯލްޓް ނައްތާލާށެވެ",
"dialog.server.status.default": "ޑީފޯލްޓް",
"dialog.project.edit.title": "އެޑިޓް ޕްރޮޖެކްޓް",
"dialog.project.edit.icon": "އައިކޮން",
"dialog.project.edit.icon.alt": "ޕްރޮޖެކްޓް އައިކޮން",
@@ -816,7 +813,6 @@ export const dict = {
"server.connect.address.invalid": "ސައްހަ HTTP ނުވަތަ HTTPS ސަރވަރ އެޑްރެހެއް ލިޔެލާށެވެ.",
"server.connect.failed":
"ގުޅެވޭ ގޮތެއް ނުވިއެވެ. ސަރވަރ އެޑްރެހާއި ޕާސްވޯޑް ޗެކްކޮށްލުމަށްފަހު އަލުން މަސައްކަތް ކުރާށެވެ.",
"server.connect.pair.description": "ކަނެކްޝަންގެ ތަފްސީލު ހޯދުމަށްޓަކައި ކޮމްޕިއުޓަރުގައި މި ކޮމާންޑް ހިންގާށެވެ.",
"server.connect.scan": "QR ކޯޑް ސްކޭން ކުރާށެވެ",
"server.connect.scan.description": "އޮޕަންކޯޑް ޖޯޑުން ދައްކާ QR ކޯޑަށް ކެމެރާ އިޝާރާތް ކުރާށެވެ.",
"server.connect.scan.invalid": "މިއީ OpenCode ޖޯޑު ކޯޑެއް ނޫނެވެ. އޮޕަންކޯޑް ޕެއާ އިން ދައްކާ ކޯޑް ސްކޭން ކުރާށެވެ.",
@@ -824,8 +820,6 @@ export const dict = {
"server.connect.camera.starting": "ކެމެރާ ހުޅުވަނީ...",
"server.connect.mixedContent":
"HTTPS ޕޭޖަކުން މި HTTP ސަރވަރ އާއި ގުޅޭ ގޮތެއް ނުވިއެވެ. އޭގެ ބަދަލުގައި HTTPS ސަރވަރ އެޑްރެހެއް ބޭނުން ކުރާށެވެ.",
"server.connect.camera.insecure":
"QR ސްކޭން ކުރުމަށް މި ޕޭޖް ހުޅުވަން ޖެހެނީ HTTPS ގެ މައްޗަށް ނުވަތަ ލޯކަލްހޯސްޓް އިންނެވެ.",
"server.connect.camera.unavailable":
"މި ބްރައުޒާއަށް އެއްވެސް ކެމެރާއެއް ނުލިބެއެވެ. ކަނެކްޝަންގެ ތަފްސީލުތައް އަމިއްލައަށް ލިޔެލާށެވެ.",
"server.connect.camera.error":
-6
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "གསང༌ཨང",
"dialog.server.edit.title": "སར་བར་ཞུན་དག་འབད།",
"dialog.server.menu.edit": "ཞུན༌དག",
"dialog.server.menu.default": "སྔོན་སྒྲིག་སྦེ་གཞི་སྒྲིག་འབད།",
"dialog.server.menu.defaultRemove": "སྔོན་སྒྲིག་རྩ་བསྐྲད་གཏང་།",
"dialog.server.status.default": "འཐུས་ཤོར",
"dialog.project.edit.title": "ཞུན་དག་ལས་གཞི།",
"dialog.project.edit.icon": "ངོས་དཔར།",
"dialog.project.edit.icon.alt": "ལས་འགུལ་ངོས་དཔར།",
@@ -797,8 +794,6 @@ export const dict = {
"server.connect.button": "མཐུད།",
"server.connect.address.invalid": "ནུས་ཅན་ HTTP ཡང་ན་ HTTPS སར་བར་ཁ་བྱང་ཅིག་བཙུགས།",
"server.connect.failed": "མཐུད་མ་ཚུགས། སར་བར་ཁ་བྱང་དང་ཆོག་ཡིག་ཞིབ་དཔྱད་འབད་ཞིནམ་ལས་ ལོག་སྟེ་འབད་རྩོལ་བསྐྱེད།",
"server.connect.pair.description":
"ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ཐོབ་ནིའི་དོན་ལུ་ ཁྱོད་རའི་གློག་རིག་གུ་བརྡ་བཀོད་འདི་གཡོག་བཀོལ།",
"server.connect.scan": "QR གསང་གྲངས་པར་བཤུས་",
"server.connect.scan.description": "ཁྱོད་རའི་པར་ཆས་འདི་ opencode pair གིས་སྟོན་མི་ QR གསང་ཡིག་ལུ་སྟོན་དགོ།",
"server.connect.scan.invalid":
@@ -807,7 +802,6 @@ export const dict = {
"server.connect.camera.starting": "པར་ཆས་ཁ་ཕྱེ་དོ...",
"server.connect.mixedContent":
"HTTPS ཤོག་ལེབ་ཅིག་ལས་ HTTP སར་བར་འདི་ལུ་མཐུད་མ་ཚུགས། དེ་གི་ཚབ་ལུ་ HTTPS སར་བར་ཁ་བྱང་ཅིག་ལག་ལེན་འཐབ།",
"server.connect.camera.insecure": "QR པར་ལེན་འབད་ནི་ལུ་ ཤོག་ལེབ་འདི་ HTTPS ཡང་ན་ localhost གུ་ཁ་ཕྱེ་དགོཔ་ཨིན།",
"server.connect.camera.unavailable": "བརྡ་འཚོལ་འདི་ལུ་པར་ཆས་མེད། ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ལག་ཐོག་ལས་བཙུགས།",
"server.connect.camera.error":
"པར་ཆས་ཁ་ཕྱེ་མ་ཚུགས། པར་ཆས་འཛུལ་སྤྱོད་འབད་བཅུགཔ་ ཡང་ན་ ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ལག་ཐོག་ལས་བཙུགས།",
-6
View File
@@ -288,9 +288,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "κωδικός πρόσβασης",
"dialog.server.edit.title": "Επεξεργασία διακομιστή",
"dialog.server.menu.edit": "Επεξεργασία",
"dialog.server.menu.default": "Ορισμός ως προεπιλογή",
"dialog.server.menu.defaultRemove": "Κατάργηση προεπιλογής",
"dialog.server.status.default": "Προεπιλογή",
"dialog.project.edit.title": "Επεξεργασία έργου",
"dialog.project.edit.icon": "Εικονίδιο",
"dialog.project.edit.icon.alt": "Εικονίδιο έργου",
@@ -796,8 +793,6 @@ export const dict = {
"server.connect.address.invalid": "Εισαγάγετε μια έγκυρη διεύθυνση διακομιστή HTTP ή HTTPS.",
"server.connect.failed":
"Δεν ήταν δυνατή η σύνδεση. Ελέγξτε τη διεύθυνση του διακομιστή και τον κωδικό πρόσβασης και, στη συνέχεια, δοκιμάστε ξανά.",
"server.connect.pair.description":
"Εκτελέστε αυτήν την εντολή στον υπολογιστή σας για να λάβετε τα στοιχεία της σύνδεσής σας.",
"server.connect.scan": "Σάρωση κωδικού QR",
"server.connect.scan.description": "Στρέψτε την κάμερά σας στον κωδικό QR που εμφανίζεται από το opencode pair.",
"server.connect.scan.invalid":
@@ -806,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "Άνοιγμα κάμερας…",
"server.connect.mixedContent":
"Δεν ήταν δυνατή η σύνδεση σε αυτόν τον διακομιστή HTTP από μια σελίδα HTTPS. Χρησιμοποιήστε μια διεύθυνση διακομιστή HTTPS.",
"server.connect.camera.insecure": "Η σάρωση QR απαιτεί άνοιγμα αυτής της σελίδας μέσω HTTPS ή localhost.",
"server.connect.camera.unavailable":
"Δεν υπάρχει κάμερα διαθέσιμη σε αυτό το πρόγραμμα περιήγησης. Εισαγάγετε τα στοιχεία της σύνδεσής σας με μη αυτόματο τρόπο.",
"server.connect.camera.error":
+36 -5
View File
@@ -86,6 +86,7 @@ export const dict = {
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",
"palette.group.recentSessions": "Recent sessions",
"dialog.provider.search.placeholder": "Search providers",
"dialog.provider.empty": "No providers found",
@@ -340,32 +341,46 @@ export const dict = {
"server.connect.title": "Connect to a server",
"server.connect.description": "Enter your server address and password to get started.",
"server.connect.description.pairing": "Paste the pairing link from the server, or scan its QR code.",
"server.connect.link": "Pairing link",
"server.connect.link.placeholder": "http://192.168.1.2:4096/auth/connect/…",
"server.connect.link.invalid":
"Paste the full pairing link from opencode pair, or from Settings → Pairing on the server's computer.",
"server.connect.password.prompt": "No pairing link?",
"server.connect.password.use": "Use password",
"server.connect.password.hint": "To see it, run opencode service get password on the server's computer.",
"server.connect.link.prompt": "Have a pairing link?",
"server.connect.link.use": "Use pairing link",
"server.connect.button": "Connect",
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
"server.connect.pair.link": "Run this command on the server's computer to get a pairing link and QR code.",
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.scan.failed": "This browser could not read the QR code. Enter your connection details manually.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.link.unreachable":
"Could not reach {{url}}. Check that this device can reach the server, then try again.",
"server.connect.link.legacy":
"This pairing code is from an older version of OpenCode. Update OpenCode on the server, then run opencode pair again.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
"Could not connect to this HTTP server from an HTTPS page. Use an HTTPS server address instead.",
"server.connect.camera.insecure": "QR scanning requires opening this page over HTTPS or on localhost.",
"server.connect.camera.native": "This page cannot use the camera over HTTP. Paste the pairing link above.",
"server.connect.camera.unavailable":
"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.",
"dialog.server.edit.title": "Edit server",
"dialog.server.signedOut":
"Your sign-in for this server is no longer valid. Paste a new pairing link, or enter the server password.",
"dialog.server.menu.edit": "Edit",
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.remove": "Remove",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.status.default": "Default",
"server.row.incompatible":
"This server is running OpenCode {{version}}, which isn't compatible with this app. Upgrade it to OpenCode V2 to continue.",
@@ -490,6 +505,7 @@ export const dict = {
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.more": "More...",
"session.view.select": "Session view",
"session.tab.add": "Add tab",
"session.tab.unknown": "Unknown Session",
@@ -661,6 +677,21 @@ export const dict = {
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.notices.title": "Third-party notices",
"settings.about.notices.description": "OpenCode includes the following open source software.",
"settings.about.notices.license": "License text",
"settings.about.notices.fonts.title": "Fonts in the Office previews",
"settings.about.notices.fonts.description":
"Each family is distributed under the SIL Open Font License 1.1, with the copyright lines below.",
"settings.about.notices.eigenpal": "Portions of BetterOffice derive from it.",
"settings.about.notices.loadFailed": "Could not load the third-party notices",
"settings.about.notices.crates.title": "Rust crates in the Office engines",
"settings.about.notices.crates.description":
"BetterOffice compiles these crates into its Word, Excel and PowerPoint engines. Where a crate offers a choice of licenses, the highlighted license applies.",
"settings.about.notices.crates.licenses": "License texts",
"settings.about.notices.crates.count.one": "{{count}} crate",
"settings.about.notices.crates.count.other": "{{count}} crates",
"settings.about.notices.crates.appliesTo": "Applies to {{names}}",
"settings.about.tagline": "AI can’t build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
-5
View File
@@ -23,7 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Ingrese una dirección de servidor HTTP o HTTPS válida.",
"server.connect.failed":
"No se pudo conectar. Verifique la dirección y la contraseña del servidor y vuelva a intentarlo.",
"server.connect.pair.description": "Ejecute este comando en su computadora para obtener los detalles de su conexión.",
"server.connect.scan": "Escanea el código QR",
"server.connect.scan.description": "Apunte su cámara al código QR mostrado por opencode pair.",
"server.connect.scan.invalid":
@@ -32,7 +31,6 @@ export const dict = {
"server.connect.camera.starting": "Abriendo cámara...",
"server.connect.mixedContent":
"No se pudo conectar a este servidor HTTP desde una página HTTPS. Utilice una dirección de servidor HTTPS en su lugar.",
"server.connect.camera.insecure": "El escaneo de QR requiere abrir esta página en HTTPS o en localhost.",
"server.connect.camera.unavailable":
"No hay ninguna cámara disponible para este navegador. Ingrese los detalles de su conexión manualmente.",
"server.connect.camera.error":
@@ -537,9 +535,6 @@ export const dict = {
"dialog.server.edit.title": "Editar servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Establecer como predeterminado",
"dialog.server.menu.defaultRemove": "Quitar como predeterminado",
"dialog.server.status.default": "Predeterminado",
"dialog.project.edit.title": "Editar proyecto",
"dialog.project.edit.icon": "Icono",
-5
View File
@@ -285,9 +285,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "parool",
"dialog.server.edit.title": "Muuda serverit",
"dialog.server.menu.edit": "Muuda",
"dialog.server.menu.default": "Määra vaikeväärtuseks",
"dialog.server.menu.defaultRemove": "Eemalda vaikeseade",
"dialog.server.status.default": "Vaikimisi",
"dialog.project.edit.title": "Redigeeri projekti",
"dialog.project.edit.icon": "Ikoon",
"dialog.project.edit.icon.alt": "Projekti ikoon",
@@ -775,7 +772,6 @@ export const dict = {
"server.connect.button": "Ühendage",
"server.connect.address.invalid": "Sisestage kehtiv serveri aadress HTTP või HTTPS.",
"server.connect.failed": "Ühendust ei õnnestunud luua. Kontrollige serveri aadressi ja parooli ning proovige uuesti.",
"server.connect.pair.description": "Ühenduse üksikasjade hankimiseks käivitage see käsk oma arvutis.",
"server.connect.scan": "Skaneeri QR-kood",
"server.connect.scan.description": "Suunake oma kaamera koodile QR, mida näitab opencode pair.",
"server.connect.scan.invalid": "See ei ole OpenCode sidumiskood. Skannige koodi, mida näitab opencode pair.",
@@ -783,7 +779,6 @@ export const dict = {
"server.connect.camera.starting": "Kaamera avamine…",
"server.connect.mixedContent":
"Selle HTTP serveriga ei saanud HTTPS lehelt ühendust luua. Kasutage selle asemel HTTPS serveri aadressi.",
"server.connect.camera.insecure": "QR skannimine nõuab selle lehe avamist HTTPS või localhost kaudu.",
"server.connect.camera.unavailable":
"Selle brauseri jaoks pole kaamerat saadaval. Sisestage ühenduse üksikasjad käsitsi.",
"server.connect.camera.error":
-5
View File
@@ -295,9 +295,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "رمز عبور",
"dialog.server.edit.title": "ویرایش سرور",
"dialog.server.menu.edit": "ویرایش کنید",
"dialog.server.menu.default": "به عنوان پیش فرض تنظیم کنید",
"dialog.server.menu.defaultRemove": "حذف پیش فرض",
"dialog.server.status.default": "پیش فرض",
"dialog.project.edit.title": "ویرایش پروژه",
"dialog.project.edit.icon": "نماد",
"dialog.project.edit.icon.alt": "نماد پروژه",
@@ -795,7 +792,6 @@ export const dict = {
"server.connect.button": "اتصال",
"server.connect.address.invalid": "یک نشانی معتبر سرور HTTP یا HTTPS وارد کنید.",
"server.connect.failed": "اتصال ممکن نشد. نشانی سرور و گذرواژه را بررسی کنید و دوباره تلاش کنید.",
"server.connect.pair.description": "برای دریافت جزئیات اتصال، این فرمان را روی رایانهٔ خود اجرا کنید.",
"server.connect.scan": "پویش کد QR",
"server.connect.scan.description": "دوربین را به‌سمت کد QR نمایش‌داده‌شده توسط opencode pair بگیرید.",
"server.connect.scan.invalid": "این کد جفت‌سازی OpenCode نیست. کدی را پویش کنید که opencode pair نشان می‌دهد.",
@@ -803,7 +799,6 @@ export const dict = {
"server.connect.camera.starting": "در حال باز کردن دوربین…",
"server.connect.mixedContent":
"اتصال به این سرور HTTP از صفحهٔ HTTPS ممکن نشد. به‌جای آن از نشانی سرور HTTPS استفاده کنید.",
"server.connect.camera.insecure": "پویش QR نیازمند باز کردن این صفحه از طریق HTTPS یا روی localhost است.",
"server.connect.camera.unavailable": "هیچ دوربینی برای این مرورگر در دسترس نیست. جزئیات اتصال را دستی وارد کنید.",
"server.connect.camera.error":
"باز کردن دوربین ممکن نشد. دسترسی دوربین را مجاز کنید یا جزئیات اتصال را دستی وارد کنید.",
-5
View File
@@ -23,7 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Anna kelvollinen HTTP- tai HTTPS-palvelinosoite.",
"server.connect.failed":
"Yhteyttä ei voitu muodostaa. Tarkista palvelimen osoite ja salasana ja yritä sitten uudelleen.",
"server.connect.pair.description": "Suorita tämä komento tietokoneellasi saadaksesi yhteystiedot.",
"server.connect.scan": "Skannaa QR-koodi",
"server.connect.scan.description": "Suuntaa kamerasi QR-koodiin, joka näkyy opencode pair:ssä.",
"server.connect.scan.invalid": "Tämä ei ole OpenCode-pariliitoskoodi. Skannaa opencode pair:n näyttämä koodi.",
@@ -31,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Avataan kameraa…",
"server.connect.mixedContent":
"Yhteyttä tähän HTTP-palvelimeen ei voitu muodostaa HTTPS-sivulta. Käytä sen sijaan HTTPS-palvelinosoitetta.",
"server.connect.camera.insecure": "QR-skannaus vaatii tämän sivun avaamisen HTTPS:n kautta tai localhostissa.",
"server.connect.camera.unavailable": "Kamera ei ole saatavilla tälle selaimelle. Syötä yhteystietosi manuaalisesti.",
"server.connect.camera.error": "Kameraa ei voitu avata. Salli kameran käyttö tai anna yhteystietosi manuaalisesti.",
"dialog.server.menu.remove": "Poista",
@@ -426,9 +424,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "salasana",
"dialog.server.edit.title": "Muokkaa palvelinta",
"dialog.server.menu.edit": "Muokkaa",
"dialog.server.menu.default": "Aseta oletukseksi",
"dialog.server.menu.defaultRemove": "Poista oletuspalvelin",
"dialog.server.status.default": "Oletus",
"dialog.project.edit.title": "Muokkaa projektia",
"dialog.project.edit.icon": "Kuvake",
"dialog.project.edit.icon.alt": "Projektikuvake",
-5
View File
@@ -285,9 +285,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "loyniorð",
"dialog.server.edit.title": "Rætta ambætara",
"dialog.server.menu.edit": "Rætta",
"dialog.server.menu.default": "Set sum forsett",
"dialog.server.menu.defaultRemove": "Strika forsett",
"dialog.server.status.default": "Forsett",
"dialog.project.edit.title": "Rætta verkætlan",
"dialog.project.edit.icon": "Ikon",
"dialog.project.edit.icon.alt": "Verkætlanarmerki",
@@ -779,7 +776,6 @@ export const dict = {
"server.connect.button": "Samband",
"server.connect.address.invalid": "Skriva eina gylduga HTTP ella HTTPS ambætaraadressu.",
"server.connect.failed": "Fekk ikki samband. Kanna ambætaraadressuna og loyniorðið, og royn síðani aftur.",
"server.connect.pair.description": "Koyr hesa skipanina á tínari teldu fyri at fáa tínar sambandsupplýsingar.",
"server.connect.scan": "Skanna QR-kotu",
"server.connect.scan.description": "Peika myndatólið á QR-kotuna, sum opencode pair vísir.",
"server.connect.scan.invalid": "Hetta er ikki ein OpenCode paringarkoda. Skanna kotuna, sum opencode pair vísir.",
@@ -787,7 +783,6 @@ export const dict = {
"server.connect.camera.starting": "Opna myndatól...",
"server.connect.mixedContent":
"Kundi ikki seta samband við hendan HTTP ambætaran frá eini HTTPS síðu. Brúka eina HTTPS ambætaraadressu ístaðin.",
"server.connect.camera.insecure": "QR-skanning krevur, at tú letur hesa síðuna upp yvir HTTPS ella á localhost.",
"server.connect.camera.unavailable":
"Einki myndatól er tøkt til henda kaga. Skriva tínar sambandsupplýsingar manuelt.",
"server.connect.camera.error":
-6
View File
@@ -23,8 +23,6 @@ export const dict = {
"server.connect.address.invalid": "Entrez une adresse de serveur HTTP ou HTTPS valide.",
"server.connect.failed":
"Impossible de se connecter. Vérifiez l'adresse et le mot de passe du serveur, puis réessayez.",
"server.connect.pair.description":
"Exécutez cette commande sur votre ordinateur pour obtenir les détails de votre connexion.",
"server.connect.scan": "Scanner le code QR",
"server.connect.scan.description": "Pointez votre appareil photo vers le code QR affiché par opencode pair.",
"server.connect.scan.invalid":
@@ -33,7 +31,6 @@ export const dict = {
"server.connect.camera.starting": "Ouverture de la caméra…",
"server.connect.mixedContent":
"Impossible de se connecter à ce serveur HTTP à partir d'une page HTTPS. Utilisez plutôt une adresse de serveur HTTPS.",
"server.connect.camera.insecure": "L'analyse QR nécessite l'ouverture de cette page sur HTTPS ou sur localhost.",
"server.connect.camera.unavailable":
"Aucune caméra n'est disponible pour ce navigateur. Saisissez manuellement vos informations de connexion.",
"server.connect.camera.error":
@@ -519,9 +516,6 @@ export const dict = {
"dialog.server.add.password": "Mot de passe (optionnel)",
"dialog.server.edit.title": "Modifier le serveur",
"dialog.server.menu.edit": "Modifier",
"dialog.server.menu.default": "Définir par défaut",
"dialog.server.menu.defaultRemove": "Ne plus utiliser par défaut",
"dialog.server.status.default": "Défaut",
"dialog.project.edit.title": "Modifier le projet",
"dialog.project.edit.icon": "Icône",
"dialog.project.edit.icon.alt": "Icône du projet",
-5
View File
@@ -294,9 +294,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "סיסמה",
"dialog.server.edit.title": "ערוך שרת",
"dialog.server.menu.edit": "ערוך",
"dialog.server.menu.default": "הגדר כברירת מחדל",
"dialog.server.menu.defaultRemove": "הסר את ברירת המחדל",
"dialog.server.status.default": "ברירת מחדל",
"dialog.project.edit.title": "ערוך פרויקט",
"dialog.project.edit.icon": "סמל",
"dialog.project.edit.icon.alt": "סמל פרויקט",
@@ -793,14 +790,12 @@ export const dict = {
"server.connect.button": "התחברות",
"server.connect.address.invalid": "יש להזין כתובת שרת HTTP או HTTPS תקינה.",
"server.connect.failed": "לא ניתן להתחבר. יש לבדוק את כתובת השרת והסיסמה ולנסות שוב.",
"server.connect.pair.description": "יש להריץ פקודה זו במחשב כדי לקבל את פרטי החיבור.",
"server.connect.scan": "סריקת קוד QR",
"server.connect.scan.description": "יש לכוון את המצלמה לקוד QR שמוצג על ידי opencode pair.",
"server.connect.scan.invalid": "זה אינו קוד צימוד של OpenCode. יש לסרוק את הקוד שמוצג על ידי opencode pair.",
"server.connect.camera": "מצלמת צימוד",
"server.connect.camera.starting": "פותח את המצלמה…",
"server.connect.mixedContent": "לא ניתן להתחבר לשרת HTTP זה מדף HTTPS. יש להשתמש בכתובת שרת HTTPS במקום זאת.",
"server.connect.camera.insecure": "סריקת QR דורשת פתיחת דף זה דרך HTTPS או ב-localhost.",
"server.connect.camera.unavailable": "אין מצלמה זמינה לדפדפן זה. יש להזין את פרטי החיבור ידנית.",
"server.connect.camera.error": "לא ניתן לפתוח את המצלמה. יש לאפשר גישה למצלמה או להזין את פרטי החיבור ידנית.",
"dialog.server.menu.remove": "הסרה",
-5
View File
@@ -292,9 +292,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "पासवर्ड",
"dialog.server.edit.title": "सर्वर संपादित करें",
"dialog.server.menu.edit": "संपादित करें",
"dialog.server.menu.default": "डिफाल्ट के रूप में सेट",
"dialog.server.menu.defaultRemove": "डिफ़ॉल्ट हटाएँ",
"dialog.server.status.default": "डिफ़ॉल्ट",
"dialog.project.edit.title": "प्रोजेक्ट संपादित करें",
"dialog.project.edit.icon": "आइकन",
"dialog.project.edit.icon.alt": "प्रोजेक्ट आइकन",
@@ -797,7 +794,6 @@ export const dict = {
"server.connect.button": "जोड़ना",
"server.connect.address.invalid": "एक वैध HTTP या HTTPS सर्वर पता दर्ज करें।",
"server.connect.failed": "कनेक्ट नहीं हो सका। सर्वर पता और पासवर्ड जांचें, फिर पुनः प्रयास करें।",
"server.connect.pair.description": "अपना कनेक्शन विवरण प्राप्त करने के लिए इस कमांड को अपने कंप्यूटर पर चलाएँ।",
"server.connect.scan": "QR कोड स्कैन करें",
"server.connect.scan.description": "अपने कैमरे को ओपनकोड जोड़ी द्वारा दिखाए गए QR कोड पर इंगित करें।",
"server.connect.scan.invalid": "यह कोई OpenCode युग्मन कोड नहीं है. ओपनकोड जोड़ी द्वारा दिखाए गए कोड को स्कैन करें।",
@@ -805,7 +801,6 @@ export const dict = {
"server.connect.camera.starting": "कैमरा खुल रहा है...",
"server.connect.mixedContent":
"किसी HTTPS पेज से इस HTTP सर्वर से कनेक्ट नहीं किया जा सका। इसके बजाय HTTPS सर्वर पते का उपयोग करें।",
"server.connect.camera.insecure": "QR स्कैनिंग के लिए इस पृष्ठ को HTTPS या लोकलहोस्ट पर खोलने की आवश्यकता है।",
"server.connect.camera.unavailable":
"इस ब्राउज़र पर कोई कैमरा उपलब्ध नहीं है. अपना कनेक्शन विवरण मैन्युअल रूप से दर्ज करें।",
"server.connect.camera.error":
-6
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "lozinka",
"dialog.server.edit.title": "Uredi poslužitelj",
"dialog.server.menu.edit": "Uredi",
"dialog.server.menu.default": "Postavi kao zadano",
"dialog.server.menu.defaultRemove": "Ukloni zadano",
"dialog.server.status.default": "Zadano",
"dialog.project.edit.title": "Uredi projekt",
"dialog.project.edit.icon": "Ikona",
"dialog.project.edit.icon.alt": "Ikona projekta",
@@ -805,7 +802,6 @@ export const dict = {
"server.connect.button": "Poveži se",
"server.connect.address.invalid": "Unesite ispravnu HTTP ili HTTPS adresu poslužitelja.",
"server.connect.failed": "Povezivanje nije uspjelo. Provjerite adresu poslužitelja i lozinku, pa pokušajte ponovno.",
"server.connect.pair.description": "Pokrenite ovu naredbu na računalu da biste dobili podatke za povezivanje.",
"server.connect.scan": "Skeniraj QR kod",
"server.connect.scan.description": "Usmjerite kameru prema QR kodu koji prikazuje opencode pair.",
"server.connect.scan.invalid": "Ovo nije OpenCode kod za uparivanje. Skenirajte kod koji prikazuje opencode pair.",
@@ -813,8 +809,6 @@ export const dict = {
"server.connect.camera.starting": "Otvaranje kamere…",
"server.connect.mixedContent":
"Nije se moguće povezati s ovim HTTP poslužiteljem s HTTPS stranice. Umjesto toga koristite HTTPS adresu poslužitelja.",
"server.connect.camera.insecure":
"Za skeniranje QR koda ovu stranicu morate otvoriti putem HTTPS-a ili na localračunalu domaćinu.",
"server.connect.camera.unavailable": "Ovom pregledniku nije dostupna kamera. Ručno unesite podatke za povezivanje.",
"server.connect.camera.error":
"Kameru nije bilo moguće otvoriti. Dozvolite pristup kameri ili ručno unesite podatke za povezivanje.",
-6
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "jelszó",
"dialog.server.edit.title": "Szerver szerkesztése",
"dialog.server.menu.edit": "Szerkesztés",
"dialog.server.menu.default": "Beállítás alapértelmezettként",
"dialog.server.menu.defaultRemove": "Alapértelmezés eltávolítása",
"dialog.server.status.default": "Alapértelmezett",
"dialog.project.edit.title": "Projekt szerkesztése",
"dialog.project.edit.icon": "Ikon",
"dialog.project.edit.icon.alt": "Projekt ikonra",
@@ -794,8 +791,6 @@ export const dict = {
"server.connect.button": "Csatlakozás",
"server.connect.address.invalid": "Adjon meg egy érvényes HTTP vagy HTTPS szervercímet.",
"server.connect.failed": "Nem sikerült csatlakozni. Ellenőrizze a szerver címét és jelszavát, majd próbálja újra.",
"server.connect.pair.description":
"Futtassa ezt a parancsot a számítógépén a kapcsolat részleteinek megtekintéséhez.",
"server.connect.scan": "QR-kód beolvasása",
"server.connect.scan.description": "Irányítsa kameráját a opencode pair által mutatott QR kódra.",
"server.connect.scan.invalid": "Ez nem egy OpenCode párosítási kód. Olvassa be a opencode pair által mutatott kódot.",
@@ -803,7 +798,6 @@ export const dict = {
"server.connect.camera.starting": "Kamera nyitása…",
"server.connect.mixedContent":
"Nem sikerült csatlakozni ehhez a HTTP szerverhez egy HTTPS oldalról. Használjon helyette HTTPS szervercímet.",
"server.connect.camera.insecure": "A QR beolvasásához ezt az oldalt a HTTPS vagy a localhost oldalon kell megnyitni.",
"server.connect.camera.unavailable":
"Ehhez a böngészőhöz nem érhető el kamera. Adja meg manuálisan a kapcsolat adatait.",
"server.connect.camera.error":
-5
View File
@@ -287,9 +287,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "գաղտնաբառ",
"dialog.server.edit.title": "Խմբագրել սերվերը",
"dialog.server.menu.edit": "Խմբագրել",
"dialog.server.menu.default": "Սահմանել որպես լռելյայն",
"dialog.server.menu.defaultRemove": "Հեռացնել լռելյայն",
"dialog.server.status.default": "Լռելյայն",
"dialog.project.edit.title": "Խմբագրել նախագիծը",
"dialog.project.edit.icon": "Պատկերակ",
"dialog.project.edit.icon.alt": "Նախագծի պատկերակ",
@@ -795,14 +792,12 @@ export const dict = {
"server.connect.button": "Կապվել",
"server.connect.address.invalid": "Մուտքագրեք վավեր HTTP կամ HTTPS սերվերի հասցե։",
"server.connect.failed": "Չկապվեց. Ստուգեք սերվերի հասցեն և գաղտնաբառը, ապա փորձեք նորից։",
"server.connect.pair.description": "Գործարկեք այս հրամանը ձեր համակարգչում՝ ստանալու ձեր կապի մանրամասները։",
"server.connect.scan": "Սկանավորեք QR կոդը",
"server.connect.scan.description": "Տեսախցիկն ուղղեք opencode pair-ի ցուցադրած QR կոդին։",
"server.connect.scan.invalid": "Սա OpenCode-ի զուգակցման կոդ չէ։ Սկանավորեք opencode pair-ի ցուցադրած կոդը։",
"server.connect.camera": "Զույգագրման վահանակը",
"server.connect.camera.starting": "Բացվում է վահանակը…",
"server.connect.mixedContent": "Այս HTTP սերվերին հնարավոր չէ միանալ HTTPS էջից։ Օգտագործեք HTTPS սերվերի հասցե։",
"server.connect.camera.insecure": "QR սկանումը պահանջում է այս էջը բացել HTTPS-ով կամ տեղական հոստում։",
"server.connect.camera.unavailable": "Այս զննարկչին վահանակ հասանելի չէ։ Մուտքագրեք ձեր կապի մանրամասները ձեռքով։",
"server.connect.camera.error":
"Չկարողացա բացել տեսախցիկը։ Թույլատրել տեսախցիկի մուտքը կամ ձեռքով ներմուծել ձեր կապի տվյալները։",
-5
View File
@@ -323,9 +323,6 @@ export const dict = {
"dialog.server.edit.title": "Sunting server",
"dialog.server.menu.edit": "Sunting",
"dialog.server.menu.default": "Tetapkan sebagai bawaan",
"dialog.server.menu.defaultRemove": "Hapus bawaan",
"dialog.server.status.default": "Bawaan",
"dialog.project.edit.title": "Sunting proyek",
"dialog.project.edit.icon": "Ikon",
@@ -862,7 +859,6 @@ export const dict = {
"server.connect.button": "Sambungkan",
"server.connect.address.invalid": "Masukkan alamat server HTTP atau HTTPS yang valid.",
"server.connect.failed": "Tidak dapat terhubung. Periksa alamat server dan kata sandi, lalu coba lagi.",
"server.connect.pair.description": "Jalankan perintah ini di komputer Anda untuk mendapatkan detail koneksi Anda.",
"server.connect.scan": "Pindai kode QR",
"server.connect.scan.description": "Arahkan kamera Anda ke kode QR yang ditampilkan oleh opencode pair.",
"server.connect.scan.invalid": "Ini bukan kode pemasangan OpenCode. Pindai kode yang ditampilkan oleh opencode pair.",
@@ -870,7 +866,6 @@ export const dict = {
"server.connect.camera.starting": "Membuka kamera…",
"server.connect.mixedContent":
"Tidak dapat terhubung ke server HTTP ini dari halaman HTTPS. Gunakan alamat server HTTPS sebagai gantinya.",
"server.connect.camera.insecure": "Pemindaian QR memerlukan halaman ini dibuka melalui HTTPS atau di localhost.",
"server.connect.camera.unavailable":
"Tidak ada kamera yang tersedia untuk peramban ini. Masukkan detail koneksi Anda secara manual.",
"server.connect.camera.error":
-5
View File
@@ -298,9 +298,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "lykilorð",
"dialog.server.edit.title": "Breyta miðlara",
"dialog.server.menu.edit": "Breyta",
"dialog.server.menu.default": "Stillt sem sjálfgefið",
"dialog.server.menu.defaultRemove": "Fjarlægja sjálfgefið",
"dialog.server.status.default": "Sjálfgefið",
"dialog.project.edit.title": "Breyta verkefni",
"dialog.project.edit.icon": "Táknmynd",
"dialog.project.edit.icon.alt": "Verkefnistákn",
@@ -782,7 +779,6 @@ export const dict = {
"server.connect.button": "Tengdu",
"server.connect.address.invalid": "Sláðu inn gilt HTTP eða HTTPS netfang netþjóns.",
"server.connect.failed": "Ekki tókst að tengjast. Athugaðu netfang netþjóns og lykilorð, reyndu svo aftur.",
"server.connect.pair.description": "Keyrðu þessa skipun á tölvunni þinni til að fá upplýsingar um tenginguna þína.",
"server.connect.scan": "Skanna QR kóða",
"server.connect.scan.description": "Beindu myndavélinni þinni að QR kóðanum sem opencode pair sýnir.",
"server.connect.scan.invalid": "Þetta er ekki OpenCode pörunarkóði. Skannaðu kóðann sem opencode pair sýnir.",
@@ -790,7 +786,6 @@ export const dict = {
"server.connect.camera.starting": "Opnar myndavél...",
"server.connect.mixedContent":
"Gat ekki tengst þessum HTTP netþjóni frá HTTPS síðu. Notaðu HTTPS netþjónsfang í staðinn.",
"server.connect.camera.insecure": "QR skönnun krefst þess að opna þessa síðu yfir HTTPS eða á localhost.",
"server.connect.camera.unavailable":
"Engin myndavél er í boði fyrir þennan vafra. Sláðu inn tengiupplýsingar þínar handvirkt.",
"server.connect.camera.error":
-6
View File
@@ -22,8 +22,6 @@ export const dict = {
"server.connect.button": "Connettiti",
"server.connect.address.invalid": "Inserisci un indirizzo server HTTP o HTTPS valido.",
"server.connect.failed": "Impossibile connettersi. Controlla l'indirizzo del server e la password, quindi riprova.",
"server.connect.pair.description":
"Esegui questo comando sul tuo computer per ottenere i dettagli della connessione.",
"server.connect.scan": "Scansiona il codice QR",
"server.connect.scan.description": "Punta la fotocamera sul codice QR mostrato da opencode pair.",
"server.connect.scan.invalid":
@@ -32,7 +30,6 @@ export const dict = {
"server.connect.camera.starting": "Apertura della fotocamera…",
"server.connect.mixedContent":
"Impossibile connettersi a questo server HTTP da una pagina HTTPS. Utilizza invece un indirizzo server HTTPS.",
"server.connect.camera.insecure": "La scansione QR richiede l'apertura di questa pagina su HTTPS o su localhost.",
"server.connect.camera.unavailable":
"Nessuna fotocamera è disponibile per questo browser. Inserisci manualmente i dettagli della tua connessione.",
"server.connect.camera.error":
@@ -449,9 +446,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Modifica server",
"dialog.server.menu.edit": "Modifica",
"dialog.server.menu.default": "Imposta come predefinito",
"dialog.server.menu.defaultRemove": "Rimuovi predefinito",
"dialog.server.status.default": "Predefinito",
"dialog.project.edit.title": "Modifica progetto",
"dialog.project.edit.icon": "Icona",
"dialog.project.edit.icon.alt": "Icona del progetto",
-5
View File
@@ -296,9 +296,6 @@ export const dict = {
"dialog.server.add.password": "パスワード (オプション)",
"dialog.server.edit.title": "サーバーを編集",
"dialog.server.menu.edit": "編集",
"dialog.server.menu.default": "デフォルトに設定",
"dialog.server.menu.defaultRemove": "デフォルト設定を解除",
"dialog.server.status.default": "デフォルト",
"dialog.project.edit.title": "プロジェクトを編集",
"dialog.project.edit.icon": "アイコン",
@@ -808,7 +805,6 @@ export const dict = {
"server.connect.button": "接続",
"server.connect.address.invalid": "有効なHTTPまたはHTTPSサーバーアドレスを入力してください。",
"server.connect.failed": "接続できませんでした。サーバーアドレスとパスワードを確認して、もう一度お試しください。",
"server.connect.pair.description": "このコマンドをコンピューターで実行して接続情報を取得してください。",
"server.connect.scan": "QRコードをスキャン",
"server.connect.scan.description": "opencode pair に表示されたQRコードにカメラを向けてください。",
"server.connect.scan.invalid":
@@ -817,7 +813,6 @@ export const dict = {
"server.connect.camera.starting": "カメラを開いています…",
"server.connect.mixedContent":
"HTTPSページからこのHTTPサーバーに接続できません。代わりにHTTPSサーバーのアドレスを使用してください。",
"server.connect.camera.insecure": "QRスキャンには、このページをHTTPSで開くか、ローカルホストで開く必要があります。",
"server.connect.camera.unavailable": "このブラウザで使用可能なカメラがありません。接続情報を手動で入力してください。",
"server.connect.camera.error":
"カメラを開くことができません。カメラへのアクセスを許可するか、接続情報を手動で入力してください。",
-5
View File
@@ -286,9 +286,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "პაროლი",
"dialog.server.edit.title": "სერვერის რედაქტირება",
"dialog.server.menu.edit": "რედაქტირება",
"dialog.server.menu.default": "დაყენება ნაგულისხმევად",
"dialog.server.menu.defaultRemove": "ნაგულისხმევის ამოღება",
"dialog.server.status.default": "ნაგულისხმევი",
"dialog.project.edit.title": "პროექტის რედაქტირება",
"dialog.project.edit.icon": "ხატულა",
"dialog.project.edit.icon.alt": "პროექტის ხატულა",
@@ -791,7 +788,6 @@ export const dict = {
"server.connect.button": "დაკავშირება",
"server.connect.address.invalid": "შეიყვანეთ ვალიდური HTTP ან HTTPS სერვერის მისამართი.",
"server.connect.failed": "ვერ მოხერხდა დაკავშირება. შეამოწმეთ სერვერის მისამართი და პაროლი, შემდეგ კვლავ სცადეთ.",
"server.connect.pair.description": "ჩაატარეთ ეს ბრძანება თქვენს კომპიუტერზე, რათა მიიღოთ თქვენი კავშირის დეტალები.",
"server.connect.scan": "სკანირება QR კოდით",
"server.connect.scan.description": "მიმართეთ კამერა opencode pair-ის მიერ ნაჩვენებ QR კოდზე.",
"server.connect.scan.invalid":
@@ -800,7 +796,6 @@ export const dict = {
"server.connect.camera.starting": "კამერის გახსნა…",
"server.connect.mixedContent":
"ვერ მოხერხდა დაკავშირება ამ HTTP სერვერთან HTTPS გვერდიდან. გამოიყენეთ HTTPS სერვერის მისამართი.",
"server.connect.camera.insecure": "QR სკანირება საჭიროებს ამ გვერდის გახსნა HTTPS-ზე ან localhost-ზე.",
"server.connect.camera.unavailable":
"ამ ბრაუზერისთვის კამერა არ არის ხელმისაწვდომი. შეიყვანეთ თქვენი კავშირის დეტალები ხელით.",
"server.connect.camera.error":
-5
View File
@@ -293,9 +293,6 @@ export const dict = {
"dialog.server.add.passwordPlaceholder": "ពាក្យសម្ងាត់",
"dialog.server.edit.title": "កែសម្រួលម៉ាស៊ីនមេ",
"dialog.server.menu.edit": "កែសម្រួល",
"dialog.server.menu.default": "កំណត់ជាលំនាំដើម",
"dialog.server.menu.defaultRemove": "លុបលំនាំដើមចេញ",
"dialog.server.status.default": "លំនាំដើម",
"dialog.project.edit.title": "កែសម្រួលគម្រោង",
"dialog.project.edit.icon": "រូបតំណាង",
"dialog.project.edit.icon.alt": "រូបតំណាងគម្រោង",
@@ -791,7 +788,6 @@ export const dict = {
"server.connect.button": "ភ្ជាប់",
"server.connect.address.invalid": "បញ្ចូលអាសយដ្ឋានម៉ាស៊ីនមេ HTTP ឬ HTTPS ត្រឹមត្រូវ។",
"server.connect.failed": "មិនអាចភ្ជាប់បានទេ។ សូមពិនិត្យអាសយដ្ឋានម៉ាស៊ីនមេ និងពាក្យសម្ងាត់ រួចព្យាយាមម្តងទៀត។",
"server.connect.pair.description": "បើកកម្មង់នេះនៅលើកុំព្យូទ័ររបស់អ្នកដើម្បីទទួលព័ត៌មានការភ្ជាប់របស់អ្នក។",
"server.connect.scan": "ស្កេនកូដ QR",
"server.connect.scan.description": "បង្ខំកាមេរ៉ារបស់អ្នកទៅកូដ QR ដែលបង្ហាញដោយ opencode pair។",
"server.connect.scan.invalid": "នេះមិនមែនជាកូដផ្គុំ OpenCode ទេ។ ស្កេនកូដដែលបង្ហាញដោយ opencode pair។",
@@ -799,7 +795,6 @@ export const dict = {
"server.connect.camera.starting": "កំពុងបើកកាមេរ៉ា…",
"server.connect.mixedContent":
"មិនអាចភ្ជាប់ទៅម៉ាស៊ីនបម្រើ HTTP នេះពីទំព័រ HTTPS។ សូមប្រើអាសយដ្ឋានម៉ាស៊ីនបម្រើ HTTPS ជំនួស។",
"server.connect.camera.insecure": "ការស្កេន QR ត្រូវការបើកទំព័រនេះតាម HTTPS ឬនៅលើ localhost។",
"server.connect.camera.unavailable":
"មិនមានកាមេរ៉ាដើម្បីប្រើប្រាស់ជាមួយកម្មវិធីនេះទេ។ សូមបញ្ចូលព័ត៌មានការភ្ជាប់ដោយដៃ។",
"server.connect.camera.error": "មិនអាចបើកកាមេរ៉ាបាន។ អនុញ្ញាតឱ្យចូលដំណើរការកាមេរ៉ា ឬបញ្ចូលព័ត៌មានការភ្ជាប់ដោយដៃ។",
Loaded 100 of 706 files, more files were not shown because too many files have changed in this diff. Show more