Compare commits

..
1 Commits
426 changed files with 7970 additions and 9432 deletions
+85 -85
View File
@@ -29,15 +29,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
## Progress
- [x] Group 1: Foundation and placement (4)
- [x] Group 2: Configuration and capability catalogs (16)
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
- [x] Group 4: Session lifecycle (12)
- [x] Group 5: Session execution and inputs (11)
- [x] Group 6: Session history and recovery (13)
- [x] Group 7: Inbox, permissions, and forms (19)
- [x] Group 8: Filesystem, worktrees, and VCS (12)
- [x] Group 9: PTYs, persistent terminals, and shells (24)
- [x] Group 10: Events, RPC, and experimental operations (6)
- [ ] Group 2: Configuration and capability catalogs (16)
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
- [ ] Group 4: Session lifecycle (12)
- [ ] Group 5: Session execution and inputs (11)
- [ ] Group 6: Session history and recovery (13)
- [ ] Group 7: Inbox, permissions, and forms (19)
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
- [ ] Group 10: Events, RPC, and experimental operations (6)
## Resolved during audit
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
@@ -100,7 +100,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
## Group 3: Credentials, integrations, MCP, and web search
@@ -111,21 +111,21 @@ Review endpoints in document order. For each endpoint, select one disposition an
|---|---|---|---|---|---|
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
@@ -147,7 +147,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.rename` | Change | Title-only rename now uses the session resource path. |
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
@@ -166,7 +166,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside the stable API. |
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
## Group 6: Session history and recovery
@@ -185,7 +185,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
@@ -196,24 +196,24 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
## Group 8: Filesystem, worktrees, and VCS
@@ -221,18 +221,18 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
## Group 9: PTYs, persistent terminals, and shells
@@ -240,30 +240,30 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
## Group 10: Events, RPC, and experimental operations
@@ -271,9 +271,9 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
| [x] | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
+36 -36
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -93,6 +93,7 @@
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -113,7 +114,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +178,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +254,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +281,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +298,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +322,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +342,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -353,7 +354,6 @@
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -168,7 +168,7 @@ else
package_scope="@opencode"
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.5",
"version": "2.0.3",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.3",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -168,10 +168,6 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
}
})
: undefined
if (input.webSocket && !channel)
yield* Effect.logWarning(`${options.name} does not offer WebSocket for this endpoint; using HTTP`, {
url: parts.url,
})
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+2 -10
View File
@@ -336,18 +336,12 @@ export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorObject = Schema.Struct({
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
@@ -440,9 +434,7 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
+1 -4
View File
@@ -715,10 +715,7 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
// Default off: strict providers 400 on unknown body fields, so only send
// the key where compatibility explicitly allows it. Header-based affinity
// (x-session-affinity, x-grok-conv-id, ...) is unaffected.
const cacheKey = (request.model.compatibility?.supportsPromptCacheKey ?? false) ? ProviderShared.promptCacheKey(request) : undefined
const cacheKey = ProviderShared.promptCacheKey(request)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
+6 -21
View File
@@ -59,15 +59,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -95,8 +87,7 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -152,11 +143,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -176,12 +163,10 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
...details,
+7 -1
View File
@@ -1,3 +1,4 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
@@ -60,6 +61,11 @@ const responsesRoute = OpenAIResponses.route.with({
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
@@ -129,7 +135,7 @@ export const configure = (input: Config) => {
const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID)
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } })
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
+1 -1
View File
@@ -44,7 +44,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false, supportsPromptCacheKey: true },
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
}),
configure,
}
+1 -1
View File
@@ -47,7 +47,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false, supportsPromptCacheKey: true },
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
}),
configure,
}
+1 -2
View File
@@ -41,8 +41,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure,
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
const chat = (modelID: string | ModelID) =>
configuredChat.model<OpenResponsesProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false, supportsPromptCacheKey: true },
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false },
})
const messages = (modelID: string | ModelID) =>
configuredMessages.model<MessagesOptionsInput>({
-1
View File
@@ -112,7 +112,6 @@ export const configure = (input: Config = {}) => {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
supportsPromptCacheKey: true,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
+1 -4
View File
@@ -91,10 +91,7 @@ export const configure = (input: Config = {}) => {
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
id,
compatibility: { supportsPromptCacheKey: true },
})
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
+1 -2
View File
@@ -183,8 +183,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) =>
route.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
configure,
}
}
-3
View File
@@ -168,9 +168,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
// key unless this is set; session-affinity headers still flow regardless.
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("does not let server codes override a 4xx rejection", () =>
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
yield* classify('{"code":"service_unavailable"}')
}),
)
+3 -47
View File
@@ -249,54 +249,10 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
).toEqual(Array(6).fill("InvalidRequest"))
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -1,9 +1,8 @@
import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -45,59 +44,11 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
+4 -21
View File
@@ -248,23 +248,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.compatible.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "compatible-model", compatibility: { supportsPromptCacheKey: true } }),
prompt: "Hello",
promptCacheKey: "session_123",
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
it.effect("omits the prompt cache key without the compatibility flag", () =>
it.effect("maps the request prompt cache key", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -277,7 +261,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
@@ -296,7 +280,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () =>
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
@@ -310,8 +294,7 @@ describe("OpenAI Chat route", () => {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
const body = decodeJson(yield* Effect.promise(() => web.text()))
// Chat uses the header; prompt_cache_key is Responses-only.
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBeUndefined()
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Logger, Ref, Schema, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -760,11 +760,7 @@ describe("OpenAI Responses route", () => {
)
const next = continuationDriver({
...request,
input: [
...firstInput,
reasoning,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
input: [...firstInput, reasoning, { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const continued = yield* next.create(saved)
@@ -799,10 +795,7 @@ describe("OpenAI Responses route", () => {
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const appended = [
...request.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
]
const appended = [...request.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }]
const changes = [
{ ...request, model: "gpt-5.3", input: appended },
{ ...request, instructions: "Changed", input: appended },
@@ -811,10 +804,7 @@ describe("OpenAI Responses route", () => {
{ ...request, metadata: { source: "two" }, input: appended },
{
...request,
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] },
appended[1],
],
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
},
]
@@ -910,10 +900,7 @@ describe("OpenAI Responses route", () => {
const second = continuationDriver(
{
...firstRequest,
input: [
...firstRequest.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
],
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
},
classifyingChannelDriver,
)
@@ -964,10 +951,7 @@ describe("OpenAI Responses route", () => {
}
const secondRequest = {
...firstRequest,
input: [
...firstRequest.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
],
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
@@ -1098,9 +1082,7 @@ describe("OpenAI Responses route", () => {
)
const expected = {
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] },
],
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
metadata: { source: "overlay\uFFFD" },
}
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
@@ -1142,9 +1124,7 @@ describe("OpenAI Responses route", () => {
}),
)
// Azure's WebSocket upgrade accepts the same credential header as HTTP: `api-key` for keys,
// `Authorization: Bearer` for Entra tokens. Rewriting a key into a bearer is rejected.
it.effect("builds Azure WebSocket requests with v1 URLs and the route's auth header", () =>
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
@@ -1153,13 +1133,13 @@ describe("OpenAI Responses route", () => {
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
headers: { "api-key": "azure-key", authorization: undefined },
authorization: "Bearer azure-key",
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
headers: { "api-key": undefined, authorization: "Bearer entra-token" },
authorization: "Bearer entra-token",
},
]
@@ -1170,8 +1150,8 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.headers.authorization)
expect(exchange.connect.headers["api-key"]).toBe(item.headers["api-key"])
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
@@ -1219,11 +1199,6 @@ describe("OpenAI Responses route", () => {
},
]
const warnings: string[] = []
const logger = Logger.make((entry) => {
if (entry.logLevel !== "Warn") return
warnings.push(String(Array.isArray(entry.message) ? entry.message[0] : entry.message))
})
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
@@ -1239,9 +1214,6 @@ describe("OpenAI Responses route", () => {
),
),
),
).pipe(Effect.provide(Logger.layer([logger])))
expect(warnings).toEqual(
cases.map(() => "Azure OpenAI Responses does not offer WebSocket for this endpoint; using HTTP"),
)
}),
)
@@ -1870,11 +1842,7 @@ describe("OpenAI Responses route", () => {
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
@@ -4631,20 +4599,8 @@ describe("OpenAI Responses route", () => {
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
)
expect(prepared.map((request) => request.body.input)).toEqual([
[
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
},
],
[
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
},
],
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
])
}),
)
@@ -62,8 +62,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
@@ -22,7 +22,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branch?*", (route) => {
await page.route("**/api/vcs/branches?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { decision: "once" },
body: { reply: "once" },
},
{
origin: serverA,
directory: undefined,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { decision: "once" },
body: { reply: "once" },
},
])
})
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
})
@@ -16,13 +16,8 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
}
return json(route, {})
})
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = input.action
row.delivery = "steer"
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: input.action,
delivery: "steer",
})
},
}
@@ -48,10 +48,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (
request.method() === "DELETE" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
)
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
rejectRequests.push(request.url())
})
@@ -110,7 +107,7 @@ test("shows a pending permission dock", async ({ page }) => {
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ decision: "once" })
expect(request.postDataJSON()).toEqual({ reply: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -138,14 +138,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({
json: {
version: "2.0.0",
pid: 1,
urls: ["http://secondary.test"],
paths: { tmp: "/tmp/opencode" },
},
}),
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
)
await page.addInitScript(
({ directory, server }) => {
@@ -23,7 +23,6 @@ for (const custom of [false, true]) {
await page.goto(stressSessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(trigger).toBeEnabled()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/info")
const response = await fetch("/api/status")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -172,6 +172,64 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
@@ -278,11 +278,7 @@ async function installMotionProbe(page: Page) {
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (
panelGap &&
reviewRegion.getBoundingClientRect().height > 1 &&
terminalRegion.getBoundingClientRect().height > 1
)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/info")
if (path === "/api/status")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -334,13 +334,8 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+5 -6
View File
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
@@ -112,10 +112,10 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
@@ -173,7 +173,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
@@ -218,9 +218,8 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { ...SessionParams, inboxID: Schema.String },
payload: Schema.Struct({ delivery: Schema.Literals(["steer", "queue"]) }),
success: NoContent,
}),
)
+4 -14
View File
@@ -42,7 +42,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
type MockStreamWindow = Window & {
@@ -219,13 +219,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
@@ -460,13 +454,9 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxUpdate: (ctx) =>
sessionInboxSteer: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({
sessionID: ctx.params.sessionID,
inboxID: ctx.params.inboxID,
action: ctx.payload.delivery,
}),
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.3",
"description": "",
"type": "module",
"exports": {
@@ -93,6 +93,7 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
"tailwindcss": "4.3.3",
"uqr": "0.1.3"
}
}
+24 -16
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,21 +107,29 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
return (
<ServersProvider
@@ -1,19 +0,0 @@
import { expect, test } from "bun:test"
import { shouldHandlePasteAsAttachment } from "./interaction"
test("leaves paste to the browser when web clipboard data is unavailable", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
})
test("uses native image reading only when the clipboard has no text", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
})
test("handles clipboard files as attachments", () => {
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
})
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
return { types, items } as unknown as DataTransfer
}
@@ -384,20 +384,20 @@ export function createComposerEditor(input: {
},
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
if (
attachments &&
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
) {
void attachments.handlePaste(event)
return
}
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
if (
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
@@ -408,13 +408,13 @@ export function createComposerEditor(input: {
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(normalized)
const node = document.createTextNode(text)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
@@ -450,12 +450,6 @@ export function createComposerEditor(input: {
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
-1
View File
@@ -15,7 +15,6 @@ export type {
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
@@ -146,7 +146,7 @@ export function createHomeSessionsController(home: HomeController) {
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.update({ sessionID: session.id, title: next })
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
-12
View File
@@ -9,10 +9,6 @@ export function useNewSessionCommands(input: {
empty: () => boolean
open: () => void
}
workspace: {
enabled: () => boolean
cycle: () => void
}
}) {
const command = useCommand()
const dialog = useDialog()
@@ -44,13 +40,5 @@ export function useNewSessionCommands(input: {
disabled: input.project.empty(),
onSelect: input.project.open,
},
{
id: "session.location.cycle",
title: language.t("command.session.location.cycle"),
category: language.t("command.category.workspace"),
keybind: "mod+alt+l",
disabled: !input.workspace.enabled(),
onSelect: input.workspace.cycle,
},
])
}
-4
View File
@@ -60,10 +60,6 @@ export default function NewSessionPage(props: { draftId: string }) {
empty: project.empty,
open: () => project.setOpen(true),
},
workspace: {
enabled: workspace.bar.visible,
cycle: workspace.selection.cycle,
},
})
createEffect(() => {
if (!composer.ready()) return
@@ -1,10 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
cycleNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -69,16 +64,4 @@ describe("new session workspace selection", () => {
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
expect(resolveNewSessionGit({})).toBe(false)
})
test("cycles between local and a new worktree", () => {
expect(cycleNewSessionWorktree({ current: "main" })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create" })).toBe("main")
})
test("includes the selected existing worktree in the cycle", () => {
const existing = "/project/feature"
expect(cycleNewSessionWorktree({ current: existing, existing })).toBe("main")
expect(cycleNewSessionWorktree({ current: "main", existing })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create", existing })).toBe(existing)
})
})
@@ -37,12 +37,6 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
return input.projectVcs === "git" || input.branch !== undefined
}
export function cycleNewSessionWorktree(input: { current: string; existing?: string }) {
if (input.current === "main") return "create"
if (input.current === "create") return input.existing ?? "main"
return "main"
}
export function createNewSessionWorkspaceController(input: {
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
@@ -55,10 +49,7 @@ export function createNewSessionWorkspaceController(input: {
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({
search: "",
existing: undefined as { projectID: string; directory: string } | undefined,
})
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
@@ -151,7 +142,7 @@ export function createNewSessionWorkspaceController(input: {
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branch.list({ location: { directory }, search, limit: 50 })
.branches({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
@@ -168,8 +159,6 @@ export function createNewSessionWorkspaceController(input: {
createEffect(() => {
const selection = value()
if (selection === "main" || selection === "create") return
const project = currentProject()
if (project) setState("existing", { projectID: project.id, directory: selection })
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
})
const branch = createMemo(() =>
@@ -187,20 +176,6 @@ export function createNewSessionWorkspaceController(input: {
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
const select = (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
}
// The remembered worktree may have been removed since it was selected. Cycling to a directory the
// inventory no longer contains would resolve back to the fallback and leave the cycle stuck.
const existing = () => {
const project = currentProject()
const previous = state.existing
if (!project || previous?.projectID !== project.id) return
if (!worktreeDirectories().some((item) => sameDirectory(item, previous.directory))) return
return previous.directory
}
return {
selection: {
@@ -218,8 +193,11 @@ export function createNewSessionWorkspaceController(input: {
input.setSelectedBranch(undefined)
},
remember,
set: select,
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
},
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
+29 -1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session",
"command.session.location.cycle": "Cycle session location",
"command.palette": "Command palette",
@@ -438,6 +437,35 @@ export const dict = {
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"command.server.pair": "Pair device",
"settings.pairing.title": "Pairing",
"settings.pairing.connection": "Local Network",
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
"pair.local.open": "Show details",
"pair.screenActive.title": "Keep screen active",
"pair.screenActive.description": "Prevent this computers display from sleeping while OpenCode is running.",
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
"pair.qr.open": "Show details",
"pair.tailscale.enable": "Enable Tailscale",
"pair.tailscale.serve": "Tailscale Serve",
"pair.description": "Connect another device to this machine's OpenCode server.",
"pair.loading": "Loading connection details…",
"pair.qr": "Pairing QR code",
"pair.copy": "Copy details",
"pair.copy.error": "Could not copy pairing details. Try again.",
"pair.urls": "URLs",
"pair.username": "Username",
"pair.password": "Password",
"pair.error": "Could not update pairing details. Try again.",
"pair.tailscale.open": "Share with Tailscale",
"pair.tailscale.opening": "Enabling Tailscale Serve…",
"pair.tailscale.title": "Tailscale",
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
"pair.tailscale.checking": "Checking Tailscale Serve…",
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
"pair.tailscale.disable": "Disable",
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -22,6 +22,12 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
error: string
url: string
@@ -101,6 +107,10 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -124,6 +134,15 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
tailscaleAvailable(): Promise<boolean>
tailscaleStatus(): Promise<PairingInfo | null>
openTailscale(): Promise<PairingInfo>
disableTailscale(): Promise<void>
}
}
export type Platform = PlatformBase &
+5 -10
View File
@@ -90,12 +90,7 @@ test("rotates HTTP and PTY clients together", async () => {
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({
version: "2.0.0-test",
pid: 1,
urls: [request.url],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -103,23 +98,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.server.info()
await transport.api.server.status()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.server.info()
await transport.api.server.status()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/info",
url: "http://127.0.0.1:4100/api/status",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/info",
url: "http://127.0.0.1:4200/api/status",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
@@ -5,7 +5,6 @@ import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { createServerTransport } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
@@ -65,44 +64,6 @@ test("bootstraps projects through the native store setter and preserves subseque
}
})
// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an
// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through.
test("recovers project metadata after the connection to the server is dropped", async () => {
const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
let dropped = 0
const requests: string[] = []
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
if (dropped >= 2) return
dropped += 1
socket.terminate()
},
data(socket, chunk) {
requests.push(String(chunk).split(" ")[0] ?? "")
socket.end(
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\naccess-control-allow-origin: *\r\nconnection: close\r\n\r\n${body}`,
)
},
},
})
const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } })
try {
const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery(
loadProjectsQuery(ServerScope.local, transport.api.project),
)
expect(dropped).toBe(2)
// happy-dom's fetch adds a CORS preflight; only the GET is the retried API call.
expect(requests.filter((method) => method === "GET")).toHaveLength(1)
expect(result).toMatchObject([{ id: "project", worktree: "/repo" }])
} finally {
server.stop(true)
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
+15 -29
View File
@@ -17,12 +17,7 @@ describe("checkServerHealth", () => {
const headers: Array<string | null> = []
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("authorization"))
return Response.json({
version: "2.0.0",
pid: 1,
urls: [server.url],
paths: { tmp: "/tmp/opencode" },
})
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
}) as typeof globalThis.fetch
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
@@ -33,19 +28,16 @@ describe("checkServerHealth", () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/info")
expect(request?.pathname).toBe("/api/status")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -60,7 +52,7 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
@@ -119,13 +111,10 @@ describe("checkServerHealth", () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = abortFromInput(input, init)
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
@@ -141,13 +130,10 @@ describe("checkServerHealth", () => {
const fetch = (async () => {
count += 1
if (count < 3) throw new TypeError("network")
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function checkServerHealth(
fetch,
headers,
})
.server.info({ signal })
.server.status({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
+9 -4
View File
@@ -8,8 +8,11 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// Retain closed paths until reopened so settings can exclude them from the server inventory.
// The Home page independently limits the visible recently closed entries.
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
@@ -48,7 +51,6 @@ export function createServerProjects(input: {
}
return {
list: current,
closed: currentClosed,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
@@ -70,7 +72,10 @@ export function createServerProjects(input: {
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
@@ -98,7 +98,7 @@ describe("createRequestQueue", () => {
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branch")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/info").catch(() => undefined)
input.queue.fetch("http://server/api/status").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/info", ms: 2_000 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
],
},
},
+1 -6
View File
@@ -146,11 +146,7 @@ function createServerController(
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = {
...metadata,
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
...project,
}
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
@@ -178,7 +174,6 @@ function createServerController(
projects: {
...projects,
list: projectsList,
resolve: enrich,
recentlyClosed: recentlyClosedList,
},
notification,
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://computer.tailnet.ts.net:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
const info = {
urls: ["http://192.168.1.2:4096"],
username: "opencode" as const,
password: "a+b & café",
}
const url = new URL(pairingUrl(info, "https://example.com"))
expect(url.origin).toBe("https://example.com")
expect(url.pathname).toBe("/connect")
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
expect(decodePairingUrl(url.search)).toEqual({
urls: ["http://192.168.1.2:4096"],
password: "a+b & café",
})
})
test("defaults to the hosted app for desktop pairing", () => {
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
"https://app.opencode.ai",
)
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
+33 -3
View File
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
@@ -19,10 +19,40 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string) {
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
const urls = [
...new Set(
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(
value: { urls?: readonly string[]; username: "opencode"; password: string },
host = "https://app.opencode.ai",
) {
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
}
+12 -2
View File
@@ -9,12 +9,15 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { isMixedContent } from "./browser"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
@@ -36,7 +39,13 @@ export function ConnectServerScreen() {
},
{ initialValue: false },
)
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -57,6 +66,7 @@ export function ConnectServerScreen() {
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
+1 -3
View File
@@ -130,9 +130,7 @@ export function createSessionQueue(input: {
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
@@ -115,7 +115,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
+1 -1
View File
@@ -112,7 +112,7 @@ export function createSessionRequestModel() {
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -237,7 +237,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
serverSDK.api.session.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
onMutate: () => {
props.onSubmit()
},
@@ -249,7 +249,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, SessionFormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
@@ -28,7 +28,7 @@ const provider: FormCreated["data"]["form"] = {
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: SessionFormReplyInput[] = []
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
@@ -45,7 +45,7 @@ function fixture() {
}
},
},
reply: async (input: SessionFormReplyInput) => {
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
@@ -1,4 +1,4 @@
import type { FormInfo, FormOption, SessionFormReplyInput, FormStringField } from "@opencode/client/promise"
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
@@ -14,7 +14,7 @@ export function createWebSearchRequest(input: {
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: SessionFormReplyInput) => Promise<unknown>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
@@ -93,7 +93,7 @@ export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: SessionFormReplyInput) => Promise<unknown>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
@@ -80,13 +80,13 @@ export function createTimelineController(input: { session: TimelineSessionSource
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? data.session.message.list(id) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
@@ -100,7 +100,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
parentID: input.session.data.parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("session.tab.session"),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
@@ -149,7 +149,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await serverSDK.api.session
.update({ sessionID: id, title: next })
.rename({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
@@ -209,7 +209,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const confirm = async () => {
await remove(props.sessionID)
@@ -6,13 +6,7 @@ import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import fuzzysort from "fuzzysort"
import {
DEFAULT_PALETTE_KEYBIND,
formatKeybind,
keyFromKeyboardEvent,
parseKeybind,
useCommand,
} from "@/shell/commands/command"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
@@ -75,6 +69,13 @@ function isModifier(key: string) {
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
}
function normalizeKey(key: string) {
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
return key.toLowerCase()
}
function recordKeybind(event: KeyboardEvent) {
if (isModifier(event.key)) return
@@ -88,7 +89,7 @@ function recordKeybind(event: KeyboardEvent) {
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
const key = keyFromKeyboardEvent(event)
const key = normalizeKey(event.key)
if (!key) return
parts.push(key)
+2
View File
@@ -7,6 +7,7 @@ export const pageIcons = {
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
pairing: "server",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
@@ -22,6 +23,7 @@ export const pageLabels = {
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
pairing: "settings.pairing.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
@@ -0,0 +1,303 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
export function SettingsPairing() {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const queryClient = useQueryClient()
const pair = platform.pair
if (!pair) return null
const local = useQuery(() => ({
queryKey: ["pairing", "local"],
queryFn: pair.info,
}))
// Reading pending query data would suspend the entire settings surface.
const localInfo = () => (local.isSuccess ? local.data : undefined)
const localHost = createMemo(() =>
localInfo()?.urls.find((value) => {
const host = new URL(value).hostname
return (
host !== "localhost" &&
!host.endsWith(".localhost") &&
!host.startsWith("127.") &&
host !== "[::1]" &&
host !== "0.0.0.0" &&
host !== "[::]"
)
}),
)
const screenActive = useQuery(() => ({
queryKey: ["pairing", "screen-active"],
queryFn: () => platform.getKeepScreenActive!(),
enabled: !!platform.getKeepScreenActive,
}))
const screenActivity = useMutation(() => ({
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
}))
const tailscale = useQuery(() => ({
queryKey: ["pairing", "tailscale-available"],
queryFn: pair.tailscaleAvailable,
}))
const tailscaleStatus = useQuery(() => ({
queryKey: ["pairing", "tailscale-status"],
queryFn: pair.tailscaleStatus,
enabled: tailscale.isSuccess && tailscale.data === true,
}))
const tailscaleServe = useMutation(() => ({
mutationFn: pair.openTailscale,
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
}))
const tailscaleDisable = useMutation(() => ({
mutationFn: pair.disableTailscale,
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
}))
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
<SettingsList>
<SettingsRow
title={language.t("settings.pairing.connection")}
description={language.t("pair.local.description")}
>
<Button
variant="neutral"
disabled={!localHost()}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()}
/>
))
}
>
{language.t("pair.local.open")}
</Button>
</SettingsRow>
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
<div data-action="settings-keep-screen-active">
<SettingsRow
title={language.t("pair.screenActive.title")}
description={language.t("pair.screenActive.description")}
>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{language.t("pair.screenActive.title")}
</Switch>
</SettingsRow>
</div>
</Show>
</SettingsList>
<Show when={screenActive.error || screenActivity.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.screenActive.error")}
</p>
</Show>
<Show when={local.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
</section>
<Show when={tailscale.isSuccess && tailscale.data === true}>
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
<Show
when={tailscaleInfo()}
fallback={
<>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
<Button
variant="neutral"
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
aria-busy={tailscaleServe.isPending}
onClick={() => tailscaleServe.mutate()}
>
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<g opacity="0.3">
<circle cx="3" cy="3" r="3" />
<circle cx="12" cy="3" r="3" />
<circle cx="21" cy="3" r="3" />
<circle cx="3" cy="21" r="3" />
<circle cx="21" cy="21" r="3" />
</g>
<circle cx="3" cy="12" r="3" />
<circle cx="12" cy="12" r="3" />
<circle cx="21" cy="12" r="3" />
<circle cx="12" cy="21" r="3" />
</svg>
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
</Button>
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
{language.t("pair.tailscale.description")}
</span>
</div>
</SettingsList>
</>
}
>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<SettingsRow
title={language.t("pair.tailscale.serve")}
description={language.t("pair.tailscale.description")}
>
<div class="flex flex-col items-end gap-2">
<Button
variant="neutral"
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("pair.tailscale.title")}
info={tailscaleInfo()}
host={tailscaleInfo()?.urls[0]}
/>
))
}
>
{language.t("pair.qr.open")}
</Button>
<Button
variant="neutral"
disabled={
!localInfo() ||
tailscaleStatus.isPending ||
tailscaleServe.isPending ||
tailscaleDisable.isPending
}
onClick={() => tailscaleDisable.mutate()}
>
{language.t("pair.tailscale.disable")}
</Button>
</div>
</SettingsRow>
</SettingsList>
</Show>
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.tailscale.error")}
</p>
</Show>
</section>
</Show>
</div>
</>
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
const language = useLanguage()
const platform = usePlatform()
const url = createMemo(() => {
if (!props.info) return
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
return pairingUrl(
{
urls: host ? [host] : props.info.urls.slice(0, 1),
username: props.info.username,
password: props.info.password,
},
host,
)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
const value = url()
if (!value) return
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
},
}))
createEffect(() => {
if (!copy.isSuccess) return
const timeout = setTimeout(() => copy.reset(), 2000)
onCleanup(() => clearTimeout(timeout))
})
const qr = createMemo(() => {
const value = url()
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
})
return (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
aria-label={language.t("pair.qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 justify-center pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={language.t("pair.copy")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
</p>
</Show>
</DialogBody>
</Dialog>
)
}
@@ -20,6 +20,20 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{
tab: "pairing",
label: "settings.pairing.title",
keywords: "pair device qr tailscale",
available: "desktop",
},
{
tab: "pairing",
label: "pair.screenActive.title",
description: "pair.screenActive.description",
target: "settings-keep-screen-active",
keywords: "display sleep awake local",
available: "desktop",
},
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
+6 -14
View File
@@ -6,19 +6,12 @@ import { useLanguage } from "@/runtime/i18n/language"
export function SettingsSearchEmpty(props: { query: string }) {
const language = useLanguage()
const [state, setState] = createStore({ query: props.query })
let container: HTMLDivElement | undefined
let quoted: HTMLSpanElement | undefined
let measure: HTMLSpanElement | undefined
const text = (query: string) => language.t("settings.search.empty.query", { query })
const update = () => {
if (!container || !measure) return
const style = getComputedStyle(container)
measure.textContent = language.t("settings.search.empty", { query: "" })
// Measure the available query space independently of its current truncated text.
const width =
container.clientWidth -
parseFloat(style.paddingInlineStart) -
parseFloat(style.paddingInlineEnd) -
measure.getBoundingClientRect().width
if (!quoted || !measure) return
const width = quoted.getBoundingClientRect().width
const fits = (query: string) => {
measure!.textContent = text(query)
return measure!.getBoundingClientRect().width <= width
@@ -47,22 +40,21 @@ export function SettingsSearchEmpty(props: { query: string }) {
createEffect(update)
onMount(() => {
if (!container || !measure) return
if (!quoted || !measure) return
// The measuring text also observes font changes that do not resize the available space.
createResizeObserver([container, measure], update)
createResizeObserver([quoted, measure], update)
})
return (
<>
<div
ref={container}
class="settings-search-empty"
role="status"
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
>
{language.rich("settings.search.empty", {
query: (
<span class="settings-search-empty-quoted">
<span ref={quoted} class="settings-search-empty-quoted">
<bdi dir="auto">{text(state.query)}</bdi>
</span>
),
+6 -8
View File
@@ -15,12 +15,11 @@
visibility: hidden;
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
cursor: default;
}
:is(.settings-search, .settings-projects-search)
.settings-search
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
@@ -38,20 +37,19 @@
background: var(--v2-overlay-simple-overlay-hover);
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
--mask-start: 16px;
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
--mask-end: 16px;
}
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
--mask-direction: to left;
}
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
@@ -7,18 +7,14 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: {
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
}) {
export function settingsProjects(context: ServerCtx) {
const tracked = context.projects.list()
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
const closed = new Set(context.projects.closed().map(pathKey))
return [
...tracked,
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
.filter((project) => !paths.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
]
}
-72
View File
@@ -803,78 +803,6 @@
padding-inline-end: 28px;
}
.settings-projects-search [data-component="text-input-v2"] {
height: 36px;
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
}
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
background: var(--v2-background-bg-layer-02);
}
.settings-projects-search [data-slot="text-input-v2-input"] {
padding-inline-end: 0;
}
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 16px;
}
.settings-projects-search [data-slot="text-input-v2-value"] {
gap: 12px;
}
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
margin-inline-end: -4px;
}
.settings-projects-empty {
width: 60%;
margin-inline: auto;
}
.settings-projects-empty .settings-search-empty {
justify-content: center;
padding-inline: 0;
}
.settings-projects-empty .settings-search-empty-quoted {
flex: 0 1 auto;
}
.settings-project-row {
position: relative;
margin-inline: 1px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
transition: background-color 120ms;
}
.settings-project-row:hover {
background: var(--v2-background-bg-layer-02);
}
.settings-project-row-content {
display: flex;
width: 100%;
min-height: 73px;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
border-radius: inherit;
text-align: start;
}
.settings-project-row-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-tab-search-clear {
position: absolute;
top: 50%;
+15 -1
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
@@ -18,6 +19,7 @@ import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsPairing } from "./pairing/pairing"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
@@ -41,6 +43,7 @@ const rootClientTabs = [
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
] as const
const serverTabs = [
@@ -187,6 +190,7 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
@@ -216,7 +220,11 @@ function RootSettings() {
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
{
items: rootClientTabs
.filter((item) => item.value !== "pairing" || !!platform.pair)
.map((item) => ({ ...item, label: language.t(item.label) })),
},
...(multiple()
? [
{
@@ -282,6 +290,9 @@ function RootSettings() {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="pairing" class="settings-panel">
<SettingsPairing />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
@@ -294,6 +305,8 @@ function RootSettings() {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
autofocus={!surface.search.state.selected}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -376,6 +389,7 @@ function ServerSettings(props: { entry: SettingsServer }) {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
+2
View File
@@ -11,6 +11,7 @@ export type SettingsRootTab =
| "appearance"
| "notifications"
| "shortcuts"
| "pairing"
| "projects"
| "workspaces"
| "providers"
@@ -44,6 +45,7 @@ const rootTabs: Record<SettingsRootTab, true> = {
appearance: true,
notifications: true,
shortcuts: true,
pairing: true,
projects: true,
workspaces: true,
providers: true,
@@ -1,230 +0,0 @@
import { Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { InlineInput } from "@opencode/ui/inline-input"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { displayName, errorMessage } from "@/shell/layout/helpers"
import { fileManagerApp } from "@/home/projects/file-manager"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { showToast } from "@/shell/notifications/toast"
import type { LocalProject } from "@/shell/state/layout"
export function SettingsProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
onOpen: (project: LocalProject) => void
}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const [store, setStore] = createStore({
menu: undefined as { x: number; y: number } | undefined,
editor: undefined as { draft: string; saving: boolean } | undefined,
})
let row: HTMLDivElement | undefined
let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined
let outside = false
const openMenu = (x: number, y: number) => {
if (!row) return
const bounds = row.getBoundingClientRect()
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
}
const openEditor = () => {
setStore("editor", { draft: displayName(props.project), saving: false })
requestAnimationFrame(() => {
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (store.editor?.saving) return
setStore("editor", undefined)
}
const saveEditor = async () => {
if (!store.editor || store.editor.saving) return
const name = store.editor.draft.trim()
if (!name || name === displayName(props.project)) {
closeEditor()
requestAnimationFrame(() => button?.focus())
return
}
setStore("editor", "saving", true)
const context = global.ensureServerCtx(props.server)
const value = name === getFilename(props.project.worktree) ? "" : name
const saved = await (props.project.id && props.project.id !== "global"
? context.sdk.api.project
.update({ projectID: props.project.id, name: value })
.then((project) => context.sync.project.update(project))
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
)
.then(() => true)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
const restore = document.activeElement === document.body || document.activeElement === input
if (saved) setStore("editor", undefined)
if (!saved) setStore("editor", "saving", false)
if (!restore) return
requestAnimationFrame(() => (saved ? button : input)?.focus())
}
return (
<div
ref={row}
data-component="settings-project-row"
class="settings-project-row group"
onContextMenu={(event) => {
if (store.editor) return
event.preventDefault()
openMenu(event.clientX, event.clientY)
}}
>
<Show
when={!store.editor}
fallback={
<div class="settings-project-row-content">
<ProjectRowContent project={props.project}>
<InlineInput
ref={input}
aria-label={language.t("common.rename")}
dir="auto"
value={store.editor?.draft ?? ""}
disabled={store.editor?.saving}
class="settings-project-row-name w-full outline-none"
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => button?.focus())
}}
onBlur={closeEditor}
/>
</ProjectRowContent>
</div>
}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
aria-haspopup="menu"
aria-expanded={!!store.menu}
class="settings-project-row-content"
onClick={() => props.onOpen(props.project)}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(bounds.left + 12, bounds.bottom)
}}
>
<ProjectRowContent project={props.project}>
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
</ProjectRowContent>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!store.menu}
onOpenChange={(open) => {
if (!open) setStore("menu", undefined)
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
outside = true
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
const restore = !outside && !store.editor
outside = false
if (restore) requestAnimationFrame(() => button?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
<Show
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
>
<Menu.Item
onSelect={() => {
if (!platform.openPath) return
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}}
>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item
onSelect={() => {
const next = row?.nextElementSibling ?? row?.previousElementSibling
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
requestAnimationFrame(() => next?.querySelector("button")?.focus())
}}
>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
return (
<span class="flex items-start gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
{props.children}
<bdi
dir="ltr"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
title={props.project.worktree}
>
{props.project.worktree}
</bdi>
</span>
</span>
)
}
@@ -1,43 +1,45 @@
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { SettingsSearchEmpty } from "../search-empty"
import { settingsProjects } from "../servers/inventory"
import { SettingsProjectRow } from "./project-row"
import "@/settings/search.css"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
active?: boolean
autofocus?: boolean
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
const [store, setStore] = createStore({ filter: "" })
let search: HTMLInputElement | undefined
const updateOverflow = () => {
if (!search) return
const offset = Math.abs(search.scrollLeft)
setStore("overflow", {
start: offset > 1,
end: search.scrollWidth - search.clientWidth - offset > 1,
})
}
createEffect(on(() => store.filter, updateOverflow))
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
})
createEffect(
on(
() => (props.active ?? true) && searchable(),
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
@@ -52,24 +54,16 @@ export const SettingsProjects: Component<{
</div>
</div>
<Show when={searchable()}>
<div class="settings-tab-search settings-projects-search">
<div class="settings-tab-search">
<TextInput
ref={(element) => {
search = element
createResizeObserver(element, updateOverflow)
}}
ref={search}
type="search"
appearance="base"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={store.filter}
data-overflow-start={store.overflow.start}
data-overflow-end={store.overflow.end}
onScroll={updateOverflow}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.projects.search.placeholder")}
aria-label={language.t("settings.projects.search.placeholder")}
showClearButton={!!store.filter}
clearIcon="circle-xmark"
onClearClick={() => {
setStore("filter", "")
search?.focus({ preventScroll: true })
@@ -87,26 +81,32 @@ export const SettingsProjects: Component<{
<Show
when={filtered().length > 0}
fallback={
<Show
when={store.filter.trim()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="flex w-full flex-col gap-2">
<Key each={filtered()} by="worktree">
<For each={filtered()}>
{(project) => (
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</Key>
</For>
</div>
</Show>
</div>
@@ -51,15 +51,6 @@ describe("command keybind helpers", () => {
).toBe(true)
})
test("matchKeybind uses the letter key for option-modified characters", () => {
expect(
matchKeybind(
parseKeybind("meta+alt+l"),
new KeyboardEvent("keydown", { key: "¬", code: "KeyL", metaKey: true, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")
+2 -9
View File
@@ -50,20 +50,13 @@ function normalizeKey(key: string) {
return key.toLowerCase()
}
export function keyFromKeyboardEvent(event: KeyboardEvent) {
const key = normalizeKey(event.key)
if (!event.altKey || /^[a-z0-9]$/.test(key)) return key
if (!event.code.startsWith("Key") || event.code.length !== 4) return key
return event.code.slice(3).toLowerCase()
}
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
return `${key}:${mask}`
}
function signatureFromEvent(event: KeyboardEvent) {
return signature(keyFromKeyboardEvent(event), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
function isAllowedEditableKeybind(id: string | undefined) {
@@ -186,7 +179,7 @@ export function parseKeybind(config: string): Keybind[] {
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = keyFromKeyboardEvent(event)
const eventKey = normalizeKey(event.key)
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
@@ -4,6 +4,7 @@ import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
import { useUpdaterAction } from "@/shell/updates/action"
import { useSettingsSurface } from "@/settings/surface"
export function DesktopCommands() {
const command = useCommand()
@@ -40,3 +41,25 @@ export function DesktopCommands() {
return null
}
export function DesktopPairingCommand() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettingsSurface()
command.register("desktop-pairing", () =>
platform.platform === "desktop" && platform.pair
? [
{
id: "server.pair",
title: language.t("command.server.pair"),
category: language.t("command.category.server"),
onSelect: () => settings.open("pairing"),
},
]
: [],
)
return null
}
+48 -25
View File
@@ -1,5 +1,5 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,6 +10,8 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
@@ -26,6 +28,7 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -33,29 +36,48 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
onMount(() => {
if (!pairing) return
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
navigate("/", { replace: true })
})
return (
<Show when={!pairing}>
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
</Show>
)
}
@@ -79,6 +101,7 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
+5 -10
View File
@@ -33,15 +33,6 @@ export default function Layout(props: ParentProps) {
},
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -62,7 +53,11 @@ export default function Layout(props: ParentProps) {
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={debugTools}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
@@ -10,6 +10,10 @@ test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
test("connect has its own layout route", () => {
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
+2
View File
@@ -67,6 +67,7 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "connect" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -106,6 +107,7 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "connect") return { type: "connect" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
@@ -116,7 +116,7 @@ function SessionTabEntry(props: {
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.update({ sessionID: value.id, title })
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
+2 -1
View File
@@ -303,6 +303,7 @@ export function Titlebar(props: {
return
}
case "settings":
case "connect":
case "home": {
const selection = layout.home.selection()
const conn =
@@ -792,7 +793,7 @@ function ChannelIndicator(props: {
if (!channel || channel === "prod") return null
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
return (
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"bin": {
+21 -18
View File
@@ -4,31 +4,34 @@ import { brotliCompressSync, constants } from "node:zlib"
import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return "{}"
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
return JSON.stringify(
Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
// Independent entries let the server materialize only assets the browser requests.
return [key, compress(body)] as const
}),
),
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
),
)
return compress(assets)
}
function compress(body: Buffer) {
return brotliCompressSync(body, {
params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
function compress(assets: object) {
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
+1 -2
View File
@@ -74,7 +74,7 @@ const appAssetsPlugin: BunPlugin = {
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${appArchive}`,
contents: `export default ${JSON.stringify(appArchive)}`,
}))
},
}
@@ -140,7 +140,6 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [
"--smol",
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
+11 -10
View File
@@ -2,7 +2,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode/client/effect/service"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { ServerStatus } from "@opencode/protocol/groups/server"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -33,7 +33,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -42,25 +42,26 @@ try {
const credential = btoa(`opencode:${info.password}`)
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const serverInfo = await waitForReady(info.url, headers)
if (serverInfo.pid !== info.pid) throw new Error("Server info does not match registration")
const tokenInfo = await fetch(new URL(`/api/info?auth_token=${token}`, info.url), {
const status = await waitForReady(info.url, headers)
if (status.pid !== info.pid) throw new Error("Status process does not match registration")
const tokenStatus = await fetch(new URL(`/api/status?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
if (tokenInfo.status !== 200) throw new Error("Compiled service rejected query authentication")
if (tokenStatus.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedInfo.status !== 401) throw new Error("Compiled service exposed info without authentication")
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -128,11 +129,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const response = await fetch(new URL("/api/info", url), {
const response = await fetch(new URL("/api/status", url), {
headers,
signal: AbortSignal.timeout(1_000),
}).catch(() => undefined)
if (response?.ok) return Schema.decodeUnknownPromise(ServerInfo)(await response.json())
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")
+1 -1
View File
@@ -165,7 +165,7 @@ export async function streamTurn(input: {
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.session.form
await input.client.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
+1 -1
View File
@@ -55,7 +55,7 @@ export async function replyPermission(input: {
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
decision: reply,
reply,
})
}
+18 -21
View File
@@ -1,51 +1,48 @@
import { Effect, FileSystem, Option } from "effect"
import { readFileSync } from "node:fs"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, string>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && (Object.keys(embedded.value.default).length > 0 || !OPENCODE_LOCAL))
return lazy(embedded.value.default, (key) =>
brotliDecompressSync(Buffer.from(embedded.value.default[key]!, "base64")),
)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return yield* sourceAssets()
return decode(yield* sourceAssets())
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
const assets = Object.fromEntries(
return Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
return [file, target] as const
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
return lazy(assets, (key) => readFileSync(assets[key]!))
})
function lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap {
// Immutable browser caching makes retaining decompressed copies in the server unnecessary.
return new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string" || assets[key] === undefined) return
const body = load(key)
return isText(key) ? Buffer.from(body).toString() : body
},
},
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
)
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default Runtime.handler(
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
)).urls
const info = { urls, username: "opencode", password }
process.stdout.write(
+9 -44
View File
@@ -20,7 +20,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const empty: Info = {}
export const layer = Layer.effect(
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
: undefined
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
return parseRecord(text)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
})
const write = Effect.fnUntraced(function* (text: string) {
@@ -61,9 +61,6 @@ export const layer = Layer.effect(
}),
),
)
const load = Effect.fnUntraced(function* (migration?: Info) {
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
@@ -75,7 +72,8 @@ export const layer = Layer.effect(
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
return yield* load(migration?.info)
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
@@ -85,7 +83,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = yield* load(migration?.info)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
@@ -104,7 +102,7 @@ export const layer = Layer.effect(
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return merge(config, content)
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
@@ -115,39 +113,6 @@ export const layer = Layer.effect(
type Edit = { readonly path: (string | number)[]; readonly value: any }
function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
{},
),
),
() => empty,
)
}
function mergeRecords(base: object, overlay: object) {
return Object.entries(overlay).reduce<Record<string, unknown>>(
(result, [key, value]) => {
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
return result
},
{ ...base },
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function parseRecord(text: string) {
const errors: ParseError[] = []
const value: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
}
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
+4 -4
View File
@@ -145,7 +145,7 @@ export async function runNonInteractivePrompt(input: Input) {
.reply({
sessionID: input.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
reply: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
@@ -155,7 +155,7 @@ export async function runNonInteractivePrompt(input: Input) {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.session.form.cancel(
await input.client.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
@@ -695,10 +695,10 @@ export async function runNonInteractivePrompt(input: Input) {
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.session.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form
: input.client.form.request
.list({
location: { directory: input.location.directory },
})

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